[go: nahoru, domu]

Adding Mario scrolling feature to LazyRow and LazyColumn.

1. Adding Mario Scrolling feature in Row, Column, TvLazyRow,
   TvLazyColumn, TvLazyVerticalGrid and TvLazyHorizontalGrid
   under tv.compose package
2. Adding a gradle task
   (:tv:compose:compose-core:doCopiedFilesNeedUpdate)
   to keep track of the original files from which copies were made.

Most of the files are just copied over from their source locations
in the compose library.
The main change is in the `relocationDistance` function in
MarioScrollable.kt

Change-Id: I18a90b2e205d1b97bddf10005d1e80c15cd91db2
Test: Manual - tested the change on emulator
Relnote: "Adding new API in tv.compose project to support
         Mario Scrolling feature in Row, Column, TvLazyRow,
         TvLazyColumn, TvLazyVerticalGrid and TvLazyHorizontalGrid"
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 36e5380..95b9e85 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -299,6 +299,7 @@
 # > Task :glance:glance:reportLibraryMetrics
 Info: Stripped invalid locals information from [0-9]+ methods?\.
 Info: Methods with invalid locals information:
+void androidx\.tv\.foundation\.lazy\.list\.LazyListKt\.LazyList\(androidx\.compose\.ui\.Modifier, androidx\.tv\.foundation\.lazy\.list\.TvLazyListState, androidx\.compose\.foundation\.layout\.PaddingValues, boolean, boolean, boolean, androidx\.tv\.foundation\.PivotOffsets, androidx\.compose\.ui\.Alignment\$Horizontal, androidx\.compose\.foundation\.layout\.Arrangement\$Vertical, androidx\.compose\.ui\.Alignment\$Vertical, androidx\.compose\.foundation\.layout\.Arrangement\$Horizontal, kotlin\.jvm\.functions\.Function[0-9]+, androidx\.compose\.runtime\.Composer, int, int, int\)
 androidx\.compose\.ui\.Modifier androidx\.compose\.animation\.AnimationModifierKt\$animateContentSize\$[0-9]+\.invoke\(androidx\.compose\.ui\.Modifier, androidx\.compose\.runtime\.Composer, int\)
 androidx\.compose\.ui\.Modifier androidx\.compose\.material[0-9]+\.SliderKt\$sliderTapModifier\$[0-9]+\.invoke\(androidx\.compose\.ui\.Modifier, androidx\.compose\.runtime\.Composer, int\)
 androidx\.compose\.ui\.Modifier androidx\.compose\.animation\.demos\.layoutanimation\.AnimatedPlacementDemoKt\$animatePlacement\$[0-9]+\.invoke\(androidx\.compose\.ui\.Modifier, androidx\.compose\.runtime\.Composer, int\)
@@ -321,6 +322,7 @@
 void androidx\.compose\.foundation\.demos\.relocation\.BringIntoViewAndroidInteropDemoKt\.BringIntoViewAndroidInteropDemo\(androidx\.compose\.runtime\.Composer, int\)
 void androidx\.compose\.ui\.demos\.keyinput\.InterceptEnterToSendMessageDemoKt\.InterceptEnterToSendMessageDemo\(androidx\.compose\.runtime\.Composer, int\)
 Information in locals\-table is invalid with respect to the stack map table\. Local refers to non\-present stack map type for register: [0-9]+ with constraint [\-A-Z]*\.
+void androidx\.tv\.foundation\.lazy\.grid\.LazyGridKt\.LazyGrid\(androidx\.compose\.ui\.Modifier, androidx\.tv\.foundation\.lazy\.grid\.TvLazyGridState, kotlin\.jvm\.functions\.Function[0-9]+, androidx\.compose\.foundation\.layout\.PaddingValues, boolean, boolean, boolean, androidx\.compose\.foundation\.layout\.Arrangement\$Vertical, androidx\.compose\.foundation\.layout\.Arrangement\$Horizontal, androidx\.tv\.foundation\.PivotOffsets, kotlin\.jvm\.functions\.Function[0-9]+, androidx\.compose\.runtime\.Composer, int, int, int\)
 androidx\.compose\.ui\.Modifier androidx\.compose\.material\.SliderKt\$sliderTapModifier\$[0-9]+\.invoke\(androidx\.compose\.ui\.Modifier, androidx\.compose\.runtime\.Composer, int\)
 androidx\.compose\.ui\.Modifier androidx\.compose\.foundation\.FocusableKt\$focusable\$[0-9]+\.invoke\(androidx\.compose\.ui\.Modifier, androidx\.compose\.runtime\.Composer, int\)
 androidx\.compose\.ui\.Modifier androidx\.compose\.foundation\.ScrollKt\$scroll\$[0-9]+\.invoke\(androidx\.compose\.ui\.Modifier, androidx\.compose\.runtime\.Composer, int\)
@@ -439,6 +441,8 @@
 # > Task :tv:tv-material:processReleaseManifest
 # > Task :tv:tv-foundation:processReleaseManifest
 package="androidx\.tv\..*" found in source AndroidManifest\.xml: \$OUT_DIR/androidx/tv/tv\-[a-z]+/build/intermediates/tmp/ProcessLibraryManifest/[a-z]+/tempAndroidManifest[0-9]+\.xml\.
+void androidx.tv.foundation.lazy.list.LazyListKt.LazyList(androidx.compose.ui.Modifier, androidx.tv.foundation.lazy.list.TvLazyListState, androidx.compose.foundation.layout.PaddingValues, boolean, boolean, boolean, androidx.tv.foundation.PivotOffsets, androidx.compose.ui.Alignment$Horizontal, androidx.compose.foundation.layout.Arrangement$Vertical, androidx.compose.ui.Alignment$Vertical, androidx.compose.foundation.layout.Arrangement$Horizontal, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int)
+void androidx.tv.foundation.lazy.grid.LazyGridKt.LazyGrid(androidx.compose.ui.Modifier, androidx.tv.foundation.lazy.grid.TvLazyGridState, kotlin.jvm.functions.Function2, androidx.compose.foundation.layout.PaddingValues, boolean, boolean, boolean, androidx.compose.foundation.layout.Arrangement$Vertical, androidx.compose.foundation.layout.Arrangement$Horizontal, androidx.tv.foundation.PivotOffsets, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int)
 # > Task :room:integration-tests:room-testapp:mergeDexWithExpandProjectionDebugAndroidTest
 WARNING:D[0-9]+: Application does not contain `androidx\.tracing\.Trace` as referenced in main\-dex\-list\.
 # > Task :hilt:hilt-compiler:kaptTestKotlin
@@ -468,3 +472,4 @@
 # > Task :buildSrc-tests:test
 WARNING: Illegal reflective access using Lookup on org\.gradle\.internal\.classloader\.ClassLoaderUtils\$AbstractClassLoaderLookuper .* to class java\.lang\.ClassLoader
 WARNING: Please consider reporting this to the maintainers of org\.gradle\.internal\.classloader\.ClassLoaderUtils\$AbstractClassLoaderLookuper
+
diff --git a/settings.gradle b/settings.gradle
index d3d7d62..12050a6 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -844,8 +844,8 @@
 includeProject(":tracing:tracing-perfetto-common")
 includeProject(":transition:transition", [BuildType.MAIN, BuildType.FLAN])
 includeProject(":transition:transition-ktx", [BuildType.MAIN, BuildType.FLAN])
-includeProject(":tv:tv-foundation", [BuildType.MAIN, BuildType.COMPOSE])
-includeProject(":tv:tv-material", [BuildType.MAIN, BuildType.COMPOSE])
+includeProject(":tv:tv-foundation", [BuildType.COMPOSE])
+includeProject(":tv:tv-material", [BuildType.COMPOSE])
 includeProject(":tvprovider:tvprovider", [BuildType.MAIN])
 includeProject(":vectordrawable:integration-tests:testapp", [BuildType.MAIN])
 includeProject(":vectordrawable:vectordrawable", [BuildType.MAIN])
diff --git a/tv/tv-foundation/api/current.txt b/tv/tv-foundation/api/current.txt
index e6f50d0..567cf50 100644
--- a/tv/tv-foundation/api/current.txt
+++ b/tv/tv-foundation/api/current.txt
@@ -1 +1,279 @@
 // Signature format: 4.0
+package androidx.tv.foundation {
+
+  public final class MarioScrollableKt {
+    method public static androidx.compose.ui.Modifier marioScrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.tv.foundation.PivotOffsets pivotOffsets, optional boolean enabled, optional boolean reverseDirection);
+  }
+
+  public final class PivotOffsets {
+    ctor public PivotOffsets(optional float parentFraction, optional float childFraction);
+    method public float getChildFraction();
+    method public float getParentFraction();
+    property public final float childFraction;
+    property public final float parentFraction;
+  }
+
+}
+
+package androidx.tv.foundation.lazy {
+
+  public final class LazyBeyondBoundsModifierKt {
+  }
+
+  public final class LazyListPinningModifierKt {
+  }
+
+}
+
+package androidx.tv.foundation.lazy.grid {
+
+  public final class LazyGridDslKt {
+    method @androidx.compose.runtime.Composable public static void TvLazyHorizontalGrid(androidx.tv.foundation.lazy.grid.TvGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.grid.TvLazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridScope,kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void TvLazyVerticalGrid(androidx.tv.foundation.lazy.grid.TvGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.grid.TvLazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridScope,kotlin.Unit> content);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.grid.TvLazyGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.grid.TvLazyGridScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.grid.TvLazyGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.grid.TvLazyGridScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+  }
+
+  public final class LazyGridItemPlacementAnimatorKt {
+  }
+
+  public final class LazyGridItemsProviderImplKt {
+  }
+
+  public final class LazyGridKt {
+  }
+
+  public final class LazyGridMeasureKt {
+  }
+
+  public final class LazyGridScrollingKt {
+  }
+
+  public final class LazyGridSpanKt {
+    method public static long TvGridItemSpan(int currentLineSpan);
+  }
+
+  public final class LazySemanticsKt {
+  }
+
+  @androidx.compose.runtime.Stable public interface TvGridCells {
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  public static final class TvGridCells.Adaptive implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.Adaptive(float minSize);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  public static final class TvGridCells.Fixed implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.Fixed(int count);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TvGridItemSpan {
+    method public int getCurrentLineSpan();
+    property public final int currentLineSpan;
+  }
+
+  public sealed interface TvLazyGridItemInfo {
+    method public int getColumn();
+    method public int getIndex();
+    method public Object getKey();
+    method public long getOffset();
+    method public int getRow();
+    method public long getSize();
+    property public abstract int column;
+    property public abstract int index;
+    property public abstract Object key;
+    property public abstract long offset;
+    property public abstract int row;
+    property public abstract long size;
+    field public static final androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo.Companion Companion;
+    field public static final int UnknownColumn = -1; // 0xffffffff
+    field public static final int UnknownRow = -1; // 0xffffffff
+  }
+
+  public static final class TvLazyGridItemInfo.Companion {
+    field public static final int UnknownColumn = -1; // 0xffffffff
+    field public static final int UnknownRow = -1; // 0xffffffff
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridItemScope {
+  }
+
+  @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridItemSpanScope {
+    method public int getMaxCurrentLineSpan();
+    method public int getMaxLineSpan();
+    property public abstract int maxCurrentLineSpan;
+    property public abstract int maxLineSpan;
+  }
+
+  public sealed interface TvLazyGridLayoutInfo {
+    method public int getAfterContentPadding();
+    method public int getBeforeContentPadding();
+    method public androidx.compose.foundation.gestures.Orientation getOrientation();
+    method public boolean getReverseLayout();
+    method public int getTotalItemsCount();
+    method public int getViewportEndOffset();
+    method public long getViewportSize();
+    method public int getViewportStartOffset();
+    method public java.util.List<androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo> getVisibleItemsInfo();
+    property public abstract int afterContentPadding;
+    property public abstract int beforeContentPadding;
+    property public abstract androidx.compose.foundation.gestures.Orientation orientation;
+    property public abstract boolean reverseLayout;
+    property public abstract int totalItemsCount;
+    property public abstract int viewportEndOffset;
+    property public abstract long viewportSize;
+    property public abstract int viewportStartOffset;
+    property public abstract java.util.List<androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo> visibleItemsInfo;
+  }
+
+  @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridScope {
+    method public void item(optional Object? key, optional kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,kotlin.Unit> content);
+    method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+  }
+
+  @kotlin.DslMarker public @interface TvLazyGridScopeMarker {
+  }
+
+  @androidx.compose.runtime.Stable public final class TvLazyGridState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public TvLazyGridState(optional int firstVisibleItemIndex, optional int firstVisibleItemScrollOffset);
+    method public suspend Object? animateScrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public float dispatchRawDelta(float delta);
+    method public int getFirstVisibleItemIndex();
+    method public int getFirstVisibleItemScrollOffset();
+    method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
+    method public androidx.tv.foundation.lazy.grid.TvLazyGridLayoutInfo getLayoutInfo();
+    method public boolean isScrollInProgress();
+    method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? scrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final int firstVisibleItemIndex;
+    property public final int firstVisibleItemScrollOffset;
+    property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
+    property public boolean isScrollInProgress;
+    property public final androidx.tv.foundation.lazy.grid.TvLazyGridLayoutInfo layoutInfo;
+    field public static final androidx.tv.foundation.lazy.grid.TvLazyGridState.Companion Companion;
+  }
+
+  public static final class TvLazyGridState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.grid.TvLazyGridState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.grid.TvLazyGridState,?> Saver;
+  }
+
+  public final class TvLazyGridStateKt {
+    method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.grid.TvLazyGridState rememberTvLazyGridState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
+  }
+
+}
+
+package androidx.tv.foundation.lazy.list {
+
+  public final class LazyDslKt {
+    method @androidx.compose.runtime.Composable public static void TvLazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.list.TvLazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListScope,kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void TvLazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.list.TvLazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListScope,kotlin.Unit> content);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.list.TvLazyListScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.list.TvLazyListScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.list.TvLazyListScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.list.TvLazyListScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+  }
+
+  public final class LazyListHeadersKt {
+  }
+
+  public final class LazyListItemPlacementAnimatorKt {
+  }
+
+  public final class LazyListItemsProviderImplKt {
+  }
+
+  public final class LazyListKt {
+  }
+
+  public final class LazyListMeasureKt {
+  }
+
+  public final class LazyListScrollingKt {
+  }
+
+  public final class LazyListStateKt {
+    method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.list.TvLazyListState rememberTvLazyListState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
+  }
+
+  public final class LazySemanticsKt {
+  }
+
+  public interface TvLazyListItemInfo {
+    method public int getIndex();
+    method public Object getKey();
+    method public int getOffset();
+    method public int getSize();
+    property public abstract int index;
+    property public abstract Object key;
+    property public abstract int offset;
+    property public abstract int size;
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.foundation.lazy.list.TvLazyListScopeMarker public sealed interface TvLazyListItemScope {
+    method public androidx.compose.ui.Modifier fillParentMaxHeight(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method public androidx.compose.ui.Modifier fillParentMaxSize(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method public androidx.compose.ui.Modifier fillParentMaxWidth(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+  }
+
+  public sealed interface TvLazyListLayoutInfo {
+    method public int getAfterContentPadding();
+    method public int getBeforeContentPadding();
+    method public androidx.compose.foundation.gestures.Orientation getOrientation();
+    method public boolean getReverseLayout();
+    method public int getTotalItemsCount();
+    method public int getViewportEndOffset();
+    method public long getViewportSize();
+    method public int getViewportStartOffset();
+    method public java.util.List<androidx.tv.foundation.lazy.list.TvLazyListItemInfo> getVisibleItemsInfo();
+    property public abstract int afterContentPadding;
+    property public abstract int beforeContentPadding;
+    property public abstract androidx.compose.foundation.gestures.Orientation orientation;
+    property public abstract boolean reverseLayout;
+    property public abstract int totalItemsCount;
+    property public abstract int viewportEndOffset;
+    property public abstract long viewportSize;
+    property public abstract int viewportStartOffset;
+    property public abstract java.util.List<androidx.tv.foundation.lazy.list.TvLazyListItemInfo> visibleItemsInfo;
+  }
+
+  @androidx.tv.foundation.lazy.list.TvLazyListScopeMarker public sealed interface TvLazyListScope {
+    method public void item(optional Object? key, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,kotlin.Unit> content);
+    method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+  }
+
+  @kotlin.DslMarker public @interface TvLazyListScopeMarker {
+  }
+
+  @androidx.compose.runtime.Stable public final class TvLazyListState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public TvLazyListState(optional int firstVisibleItemIndex, optional int firstVisibleItemScrollOffset);
+    method public suspend Object? animateScrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public float dispatchRawDelta(float delta);
+    method public int getFirstVisibleItemIndex();
+    method public int getFirstVisibleItemScrollOffset();
+    method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
+    method public androidx.tv.foundation.lazy.list.TvLazyListLayoutInfo getLayoutInfo();
+    method public boolean isScrollInProgress();
+    method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? scrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final int firstVisibleItemIndex;
+    property public final int firstVisibleItemScrollOffset;
+    property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
+    property public boolean isScrollInProgress;
+    property public final androidx.tv.foundation.lazy.list.TvLazyListLayoutInfo layoutInfo;
+    field public static final androidx.tv.foundation.lazy.list.TvLazyListState.Companion Companion;
+  }
+
+  public static final class TvLazyListState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.list.TvLazyListState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.list.TvLazyListState,?> Saver;
+  }
+
+}
+
diff --git a/tv/tv-foundation/api/public_plus_experimental_current.txt b/tv/tv-foundation/api/public_plus_experimental_current.txt
index e6f50d0..f6f513b 100644
--- a/tv/tv-foundation/api/public_plus_experimental_current.txt
+++ b/tv/tv-foundation/api/public_plus_experimental_current.txt
@@ -1 +1,281 @@
 // Signature format: 4.0
+package androidx.tv.foundation {
+
+  public final class MarioScrollableKt {
+    method public static androidx.compose.ui.Modifier marioScrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.tv.foundation.PivotOffsets pivotOffsets, optional boolean enabled, optional boolean reverseDirection);
+  }
+
+  public final class PivotOffsets {
+    ctor public PivotOffsets(optional float parentFraction, optional float childFraction);
+    method public float getChildFraction();
+    method public float getParentFraction();
+    property public final float childFraction;
+    property public final float parentFraction;
+  }
+
+}
+
+package androidx.tv.foundation.lazy {
+
+  public final class LazyBeyondBoundsModifierKt {
+  }
+
+  public final class LazyListPinningModifierKt {
+  }
+
+}
+
+package androidx.tv.foundation.lazy.grid {
+
+  public final class LazyGridDslKt {
+    method @androidx.compose.runtime.Composable public static void TvLazyHorizontalGrid(androidx.tv.foundation.lazy.grid.TvGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.grid.TvLazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridScope,kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void TvLazyVerticalGrid(androidx.tv.foundation.lazy.grid.TvGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.grid.TvLazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridScope,kotlin.Unit> content);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.grid.TvLazyGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.grid.TvLazyGridScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.grid.TvLazyGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.grid.TvLazyGridScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+  }
+
+  public final class LazyGridItemPlacementAnimatorKt {
+  }
+
+  public final class LazyGridItemsProviderImplKt {
+  }
+
+  public final class LazyGridKt {
+  }
+
+  public final class LazyGridMeasureKt {
+  }
+
+  public final class LazyGridScrollingKt {
+  }
+
+  public final class LazyGridSpanKt {
+    method public static long TvGridItemSpan(int currentLineSpan);
+  }
+
+  public final class LazySemanticsKt {
+  }
+
+  @androidx.compose.runtime.Stable public interface TvGridCells {
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  public static final class TvGridCells.Adaptive implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.Adaptive(float minSize);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  public static final class TvGridCells.Fixed implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.Fixed(int count);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TvGridItemSpan {
+    method public int getCurrentLineSpan();
+    property public final int currentLineSpan;
+  }
+
+  public sealed interface TvLazyGridItemInfo {
+    method public int getColumn();
+    method public int getIndex();
+    method public Object getKey();
+    method public long getOffset();
+    method public int getRow();
+    method public long getSize();
+    property public abstract int column;
+    property public abstract int index;
+    property public abstract Object key;
+    property public abstract long offset;
+    property public abstract int row;
+    property public abstract long size;
+    field public static final androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo.Companion Companion;
+    field public static final int UnknownColumn = -1; // 0xffffffff
+    field public static final int UnknownRow = -1; // 0xffffffff
+  }
+
+  public static final class TvLazyGridItemInfo.Companion {
+    field public static final int UnknownColumn = -1; // 0xffffffff
+    field public static final int UnknownRow = -1; // 0xffffffff
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridItemScope {
+    method @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.ui.Modifier animateItemPlacement(androidx.compose.ui.Modifier, optional androidx.compose.animation.core.FiniteAnimationSpec<androidx.compose.ui.unit.IntOffset> animationSpec);
+  }
+
+  @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridItemSpanScope {
+    method public int getMaxCurrentLineSpan();
+    method public int getMaxLineSpan();
+    property public abstract int maxCurrentLineSpan;
+    property public abstract int maxLineSpan;
+  }
+
+  public sealed interface TvLazyGridLayoutInfo {
+    method public int getAfterContentPadding();
+    method public int getBeforeContentPadding();
+    method public androidx.compose.foundation.gestures.Orientation getOrientation();
+    method public boolean getReverseLayout();
+    method public int getTotalItemsCount();
+    method public int getViewportEndOffset();
+    method public long getViewportSize();
+    method public int getViewportStartOffset();
+    method public java.util.List<androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo> getVisibleItemsInfo();
+    property public abstract int afterContentPadding;
+    property public abstract int beforeContentPadding;
+    property public abstract androidx.compose.foundation.gestures.Orientation orientation;
+    property public abstract boolean reverseLayout;
+    property public abstract int totalItemsCount;
+    property public abstract int viewportEndOffset;
+    property public abstract long viewportSize;
+    property public abstract int viewportStartOffset;
+    property public abstract java.util.List<androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo> visibleItemsInfo;
+  }
+
+  @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridScope {
+    method public void item(optional Object? key, optional kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,kotlin.Unit> content);
+    method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+  }
+
+  @kotlin.DslMarker public @interface TvLazyGridScopeMarker {
+  }
+
+  @androidx.compose.runtime.Stable public final class TvLazyGridState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public TvLazyGridState(optional int firstVisibleItemIndex, optional int firstVisibleItemScrollOffset);
+    method public suspend Object? animateScrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public float dispatchRawDelta(float delta);
+    method public int getFirstVisibleItemIndex();
+    method public int getFirstVisibleItemScrollOffset();
+    method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
+    method public androidx.tv.foundation.lazy.grid.TvLazyGridLayoutInfo getLayoutInfo();
+    method public boolean isScrollInProgress();
+    method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? scrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final int firstVisibleItemIndex;
+    property public final int firstVisibleItemScrollOffset;
+    property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
+    property public boolean isScrollInProgress;
+    property public final androidx.tv.foundation.lazy.grid.TvLazyGridLayoutInfo layoutInfo;
+    field public static final androidx.tv.foundation.lazy.grid.TvLazyGridState.Companion Companion;
+  }
+
+  public static final class TvLazyGridState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.grid.TvLazyGridState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.grid.TvLazyGridState,?> Saver;
+  }
+
+  public final class TvLazyGridStateKt {
+    method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.grid.TvLazyGridState rememberTvLazyGridState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
+  }
+
+}
+
+package androidx.tv.foundation.lazy.list {
+
+  public final class LazyDslKt {
+    method @androidx.compose.runtime.Composable public static void TvLazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.list.TvLazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListScope,kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void TvLazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.list.TvLazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListScope,kotlin.Unit> content);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.list.TvLazyListScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.list.TvLazyListScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.list.TvLazyListScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.list.TvLazyListScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+  }
+
+  public final class LazyListHeadersKt {
+  }
+
+  public final class LazyListItemPlacementAnimatorKt {
+  }
+
+  public final class LazyListItemsProviderImplKt {
+  }
+
+  public final class LazyListKt {
+  }
+
+  public final class LazyListMeasureKt {
+  }
+
+  public final class LazyListScrollingKt {
+  }
+
+  public final class LazyListStateKt {
+    method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.list.TvLazyListState rememberTvLazyListState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
+  }
+
+  public final class LazySemanticsKt {
+  }
+
+  public interface TvLazyListItemInfo {
+    method public int getIndex();
+    method public Object getKey();
+    method public int getOffset();
+    method public int getSize();
+    property public abstract int index;
+    property public abstract Object key;
+    property public abstract int offset;
+    property public abstract int size;
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.foundation.lazy.list.TvLazyListScopeMarker public sealed interface TvLazyListItemScope {
+    method @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.ui.Modifier animateItemPlacement(androidx.compose.ui.Modifier, optional androidx.compose.animation.core.FiniteAnimationSpec<androidx.compose.ui.unit.IntOffset> animationSpec);
+    method public androidx.compose.ui.Modifier fillParentMaxHeight(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method public androidx.compose.ui.Modifier fillParentMaxSize(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method public androidx.compose.ui.Modifier fillParentMaxWidth(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+  }
+
+  public sealed interface TvLazyListLayoutInfo {
+    method public int getAfterContentPadding();
+    method public int getBeforeContentPadding();
+    method public androidx.compose.foundation.gestures.Orientation getOrientation();
+    method public boolean getReverseLayout();
+    method public int getTotalItemsCount();
+    method public int getViewportEndOffset();
+    method public long getViewportSize();
+    method public int getViewportStartOffset();
+    method public java.util.List<androidx.tv.foundation.lazy.list.TvLazyListItemInfo> getVisibleItemsInfo();
+    property public abstract int afterContentPadding;
+    property public abstract int beforeContentPadding;
+    property public abstract androidx.compose.foundation.gestures.Orientation orientation;
+    property public abstract boolean reverseLayout;
+    property public abstract int totalItemsCount;
+    property public abstract int viewportEndOffset;
+    property public abstract long viewportSize;
+    property public abstract int viewportStartOffset;
+    property public abstract java.util.List<androidx.tv.foundation.lazy.list.TvLazyListItemInfo> visibleItemsInfo;
+  }
+
+  @androidx.tv.foundation.lazy.list.TvLazyListScopeMarker public sealed interface TvLazyListScope {
+    method public void item(optional Object? key, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,kotlin.Unit> content);
+    method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+  }
+
+  @kotlin.DslMarker public @interface TvLazyListScopeMarker {
+  }
+
+  @androidx.compose.runtime.Stable public final class TvLazyListState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public TvLazyListState(optional int firstVisibleItemIndex, optional int firstVisibleItemScrollOffset);
+    method public suspend Object? animateScrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public float dispatchRawDelta(float delta);
+    method public int getFirstVisibleItemIndex();
+    method public int getFirstVisibleItemScrollOffset();
+    method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
+    method public androidx.tv.foundation.lazy.list.TvLazyListLayoutInfo getLayoutInfo();
+    method public boolean isScrollInProgress();
+    method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? scrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final int firstVisibleItemIndex;
+    property public final int firstVisibleItemScrollOffset;
+    property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
+    property public boolean isScrollInProgress;
+    property public final androidx.tv.foundation.lazy.list.TvLazyListLayoutInfo layoutInfo;
+    field public static final androidx.tv.foundation.lazy.list.TvLazyListState.Companion Companion;
+  }
+
+  public static final class TvLazyListState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.list.TvLazyListState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.list.TvLazyListState,?> Saver;
+  }
+
+}
+
diff --git a/tv/tv-foundation/api/restricted_current.txt b/tv/tv-foundation/api/restricted_current.txt
index e6f50d0..567cf50 100644
--- a/tv/tv-foundation/api/restricted_current.txt
+++ b/tv/tv-foundation/api/restricted_current.txt
@@ -1 +1,279 @@
 // Signature format: 4.0
+package androidx.tv.foundation {
+
+  public final class MarioScrollableKt {
+    method public static androidx.compose.ui.Modifier marioScrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.tv.foundation.PivotOffsets pivotOffsets, optional boolean enabled, optional boolean reverseDirection);
+  }
+
+  public final class PivotOffsets {
+    ctor public PivotOffsets(optional float parentFraction, optional float childFraction);
+    method public float getChildFraction();
+    method public float getParentFraction();
+    property public final float childFraction;
+    property public final float parentFraction;
+  }
+
+}
+
+package androidx.tv.foundation.lazy {
+
+  public final class LazyBeyondBoundsModifierKt {
+  }
+
+  public final class LazyListPinningModifierKt {
+  }
+
+}
+
+package androidx.tv.foundation.lazy.grid {
+
+  public final class LazyGridDslKt {
+    method @androidx.compose.runtime.Composable public static void TvLazyHorizontalGrid(androidx.tv.foundation.lazy.grid.TvGridCells rows, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.grid.TvLazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridScope,kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void TvLazyVerticalGrid(androidx.tv.foundation.lazy.grid.TvGridCells columns, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.grid.TvLazyGridState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridScope,kotlin.Unit> content);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.grid.TvLazyGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.grid.TvLazyGridScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.grid.TvLazyGridScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.grid.TvLazyGridScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,? super T,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+  }
+
+  public final class LazyGridItemPlacementAnimatorKt {
+  }
+
+  public final class LazyGridItemsProviderImplKt {
+  }
+
+  public final class LazyGridKt {
+  }
+
+  public final class LazyGridMeasureKt {
+  }
+
+  public final class LazyGridScrollingKt {
+  }
+
+  public final class LazyGridSpanKt {
+    method public static long TvGridItemSpan(int currentLineSpan);
+  }
+
+  public final class LazySemanticsKt {
+  }
+
+  @androidx.compose.runtime.Stable public interface TvGridCells {
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  public static final class TvGridCells.Adaptive implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.Adaptive(float minSize);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  public static final class TvGridCells.Fixed implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.Fixed(int count);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
+  @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TvGridItemSpan {
+    method public int getCurrentLineSpan();
+    property public final int currentLineSpan;
+  }
+
+  public sealed interface TvLazyGridItemInfo {
+    method public int getColumn();
+    method public int getIndex();
+    method public Object getKey();
+    method public long getOffset();
+    method public int getRow();
+    method public long getSize();
+    property public abstract int column;
+    property public abstract int index;
+    property public abstract Object key;
+    property public abstract long offset;
+    property public abstract int row;
+    property public abstract long size;
+    field public static final androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo.Companion Companion;
+    field public static final int UnknownColumn = -1; // 0xffffffff
+    field public static final int UnknownRow = -1; // 0xffffffff
+  }
+
+  public static final class TvLazyGridItemInfo.Companion {
+    field public static final int UnknownColumn = -1; // 0xffffffff
+    field public static final int UnknownRow = -1; // 0xffffffff
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridItemScope {
+  }
+
+  @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridItemSpanScope {
+    method public int getMaxCurrentLineSpan();
+    method public int getMaxLineSpan();
+    property public abstract int maxCurrentLineSpan;
+    property public abstract int maxLineSpan;
+  }
+
+  public sealed interface TvLazyGridLayoutInfo {
+    method public int getAfterContentPadding();
+    method public int getBeforeContentPadding();
+    method public androidx.compose.foundation.gestures.Orientation getOrientation();
+    method public boolean getReverseLayout();
+    method public int getTotalItemsCount();
+    method public int getViewportEndOffset();
+    method public long getViewportSize();
+    method public int getViewportStartOffset();
+    method public java.util.List<androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo> getVisibleItemsInfo();
+    property public abstract int afterContentPadding;
+    property public abstract int beforeContentPadding;
+    property public abstract androidx.compose.foundation.gestures.Orientation orientation;
+    property public abstract boolean reverseLayout;
+    property public abstract int totalItemsCount;
+    property public abstract int viewportEndOffset;
+    property public abstract long viewportSize;
+    property public abstract int viewportStartOffset;
+    property public abstract java.util.List<androidx.tv.foundation.lazy.grid.TvLazyGridItemInfo> visibleItemsInfo;
+  }
+
+  @androidx.tv.foundation.lazy.grid.TvLazyGridScopeMarker public sealed interface TvLazyGridScope {
+    method public void item(optional Object? key, optional kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,kotlin.Unit> content);
+    method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemSpanScope,? super java.lang.Integer,androidx.tv.foundation.lazy.grid.TvGridItemSpan>? span, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.grid.TvLazyGridItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+  }
+
+  @kotlin.DslMarker public @interface TvLazyGridScopeMarker {
+  }
+
+  @androidx.compose.runtime.Stable public final class TvLazyGridState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public TvLazyGridState(optional int firstVisibleItemIndex, optional int firstVisibleItemScrollOffset);
+    method public suspend Object? animateScrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public float dispatchRawDelta(float delta);
+    method public int getFirstVisibleItemIndex();
+    method public int getFirstVisibleItemScrollOffset();
+    method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
+    method public androidx.tv.foundation.lazy.grid.TvLazyGridLayoutInfo getLayoutInfo();
+    method public boolean isScrollInProgress();
+    method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? scrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final int firstVisibleItemIndex;
+    property public final int firstVisibleItemScrollOffset;
+    property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
+    property public boolean isScrollInProgress;
+    property public final androidx.tv.foundation.lazy.grid.TvLazyGridLayoutInfo layoutInfo;
+    field public static final androidx.tv.foundation.lazy.grid.TvLazyGridState.Companion Companion;
+  }
+
+  public static final class TvLazyGridState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.grid.TvLazyGridState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.grid.TvLazyGridState,?> Saver;
+  }
+
+  public final class TvLazyGridStateKt {
+    method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.grid.TvLazyGridState rememberTvLazyGridState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
+  }
+
+}
+
+package androidx.tv.foundation.lazy.list {
+
+  public final class LazyDslKt {
+    method @androidx.compose.runtime.Composable public static void TvLazyColumn(optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.list.TvLazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListScope,kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void TvLazyRow(optional androidx.compose.ui.Modifier modifier, optional androidx.tv.foundation.lazy.list.TvLazyListState state, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional boolean reverseLayout, optional androidx.compose.foundation.layout.Arrangement.Horizontal horizontalArrangement, optional androidx.compose.ui.Alignment.Vertical verticalAlignment, optional boolean userScrollEnabled, optional androidx.tv.foundation.PivotOffsets pivotOffsets, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListScope,kotlin.Unit> content);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.list.TvLazyListScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void items(androidx.tv.foundation.lazy.list.TvLazyListScope, T![] items, optional kotlin.jvm.functions.Function1<? super T,?>? key, optional kotlin.jvm.functions.Function1<? super T,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.list.TvLazyListScope, java.util.List<? extends T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+    method public static inline <T> void itemsIndexed(androidx.tv.foundation.lazy.list.TvLazyListScope, T![] items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?> contentType, kotlin.jvm.functions.Function3<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+  }
+
+  public final class LazyListHeadersKt {
+  }
+
+  public final class LazyListItemPlacementAnimatorKt {
+  }
+
+  public final class LazyListItemsProviderImplKt {
+  }
+
+  public final class LazyListKt {
+  }
+
+  public final class LazyListMeasureKt {
+  }
+
+  public final class LazyListScrollingKt {
+  }
+
+  public final class LazyListStateKt {
+    method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.list.TvLazyListState rememberTvLazyListState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
+  }
+
+  public final class LazySemanticsKt {
+  }
+
+  public interface TvLazyListItemInfo {
+    method public int getIndex();
+    method public Object getKey();
+    method public int getOffset();
+    method public int getSize();
+    property public abstract int index;
+    property public abstract Object key;
+    property public abstract int offset;
+    property public abstract int size;
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.foundation.lazy.list.TvLazyListScopeMarker public sealed interface TvLazyListItemScope {
+    method public androidx.compose.ui.Modifier fillParentMaxHeight(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method public androidx.compose.ui.Modifier fillParentMaxSize(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+    method public androidx.compose.ui.Modifier fillParentMaxWidth(androidx.compose.ui.Modifier, optional @FloatRange(from=0.0, to=1.0) float fraction);
+  }
+
+  public sealed interface TvLazyListLayoutInfo {
+    method public int getAfterContentPadding();
+    method public int getBeforeContentPadding();
+    method public androidx.compose.foundation.gestures.Orientation getOrientation();
+    method public boolean getReverseLayout();
+    method public int getTotalItemsCount();
+    method public int getViewportEndOffset();
+    method public long getViewportSize();
+    method public int getViewportStartOffset();
+    method public java.util.List<androidx.tv.foundation.lazy.list.TvLazyListItemInfo> getVisibleItemsInfo();
+    property public abstract int afterContentPadding;
+    property public abstract int beforeContentPadding;
+    property public abstract androidx.compose.foundation.gestures.Orientation orientation;
+    property public abstract boolean reverseLayout;
+    property public abstract int totalItemsCount;
+    property public abstract int viewportEndOffset;
+    property public abstract long viewportSize;
+    property public abstract int viewportStartOffset;
+    property public abstract java.util.List<androidx.tv.foundation.lazy.list.TvLazyListItemInfo> visibleItemsInfo;
+  }
+
+  @androidx.tv.foundation.lazy.list.TvLazyListScopeMarker public sealed interface TvLazyListScope {
+    method public void item(optional Object? key, optional Object? contentType, kotlin.jvm.functions.Function1<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,kotlin.Unit> content);
+    method public void items(int count, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?> contentType, kotlin.jvm.functions.Function2<? super androidx.tv.foundation.lazy.list.TvLazyListItemScope,? super java.lang.Integer,kotlin.Unit> itemContent);
+  }
+
+  @kotlin.DslMarker public @interface TvLazyListScopeMarker {
+  }
+
+  @androidx.compose.runtime.Stable public final class TvLazyListState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public TvLazyListState(optional int firstVisibleItemIndex, optional int firstVisibleItemScrollOffset);
+    method public suspend Object? animateScrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public float dispatchRawDelta(float delta);
+    method public int getFirstVisibleItemIndex();
+    method public int getFirstVisibleItemScrollOffset();
+    method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
+    method public androidx.tv.foundation.lazy.list.TvLazyListLayoutInfo getLayoutInfo();
+    method public boolean isScrollInProgress();
+    method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public suspend Object? scrollToItem(int index, optional int scrollOffset, optional kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    property public final int firstVisibleItemIndex;
+    property public final int firstVisibleItemScrollOffset;
+    property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
+    property public boolean isScrollInProgress;
+    property public final androidx.tv.foundation.lazy.list.TvLazyListLayoutInfo layoutInfo;
+    field public static final androidx.tv.foundation.lazy.list.TvLazyListState.Companion Companion;
+  }
+
+  public static final class TvLazyListState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.list.TvLazyListState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.tv.foundation.lazy.list.TvLazyListState,?> Saver;
+  }
+
+}
+
diff --git a/tv/tv-foundation/build.gradle b/tv/tv-foundation/build.gradle
index c4f9fe0..4bd3f05 100644
--- a/tv/tv-foundation/build.gradle
+++ b/tv/tv-foundation/build.gradle
@@ -14,21 +14,57 @@
  * limitations under the License.
  */
 
+import androidx.build.AndroidXComposePlugin
 import androidx.build.LibraryType
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+import java.security.MessageDigest
+import java.util.stream.Collectors
 
 plugins {
     id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
     id("com.android.library")
     id("org.jetbrains.kotlin.android")
 }
 
 dependencies {
     api(libs.kotlinStdlib)
-    // Add dependencies here
+
+    api("androidx.annotation:annotation:1.1.0")
+    api(project(":compose:animation:animation"))
+    api(project(':compose:runtime:runtime'))
+    api(project(":compose:ui:ui"))
+
+    implementation(libs.kotlinStdlibCommon)
+    implementation(project(":compose:foundation:foundation"))
+    implementation(project(":compose:foundation:foundation-layout"))
+    implementation(project(":compose:ui:ui-graphics"))
+    implementation(project(":compose:ui:ui-text"))
+    implementation(project(":compose:ui:ui-util"))
+    implementation("androidx.profileinstaller:profileinstaller:1.2.0-alpha02")
+
+    testImplementation(libs.testRules)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.junit)
+    implementation(libs.truth)
+
+    androidTestImplementation(project(":compose:ui:ui-test"))
+    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
+    androidTestImplementation(project(":compose:test-utils"))
+    androidTestImplementation(libs.testRunner)
 }
 
 android {
     namespace "androidx.tv.foundation"
+    defaultConfig {
+        minSdkVersion 28
+    }
+    // Use Robolectric 4.+
+    testOptions.unitTests.includeAndroidResources = true
+    lintOptions {
+        disable 'IllegalExperimentalApiUsage' // TODO (b/233188423): Address before moving to beta
+    }
 }
 
 androidx {
@@ -40,4 +76,477 @@
             "to write Jetpack Compose applications for TV devices by providing " +
             "functionality to support TV specific devices sizes, shapes and d-pad navigation " +
             "supported components. It builds upon the Jetpack Compose libraries."
+    targetsJavaConsumers = false
+}
+
+tasks.withType(KotlinCompile).configureEach {
+    kotlinOptions {
+        freeCompilerArgs += [
+                "-Xjvm-default=all",
+        ]
+    }
+}
+
+// Functions and tasks to monitor changes in copied files.
+
+task generateMd5 {
+    ext.genMd5 = { fileNameToHash ->
+        MessageDigest digest = MessageDigest.getInstance("MD5")
+        file(fileNameToHash).withInputStream(){is->
+            byte[] buffer = new byte[8192]
+            int read = 0
+            while( (read = is.read(buffer)) > 0) {
+                digest.update(buffer, 0, read);
+            }
+        }
+        byte[] md5sum = digest.digest()
+        BigInteger bigInt = new BigInteger(1, md5sum)
+        bigInt.toString(16).padLeft(32, '0')
+    }
+
+    doLast {
+        String hashValue = genMd5(file)
+        print "value="
+        println hashValue
+    }
+}
+
+List<CopiedClass> copiedClasses = new ArrayList<>();
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/MarioScrollable.kt",
+                "afaf0f2be6b57df076db42d9218f83d9"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/DataIndex.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/DataIndex.kt",
+                "2aa3c6d2dd05057478e723b2247517e1"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScopeImpl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyItemScopeImpl.kt",
+                "31e6796d0d03cb84483396a39fc5b7e7"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListHeaders.kt",
+                "4d71c69f9cb38f741da9cfc4109567dd"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt",
+                "a74bfa05e68e2b6c2e108f022dfbfa26"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProviderImpl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListItemProviderImpl.kt",
+                "57ff505cbdfa854e15b4fbd9d4a574eb"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListItemsProvider.kt",
+                "42a2c446c81fba89fd7b8480d063b308"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyList.kt",
+                "c605794683c01c516674436c9ebc1f44"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListMeasure.kt",
+                "95c14abd0367f0f39218c9bdd175b242"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt",
+                "d4407572c6550d184133f8b3fd37869f"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScopeImpl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListScopeImpl.kt",
+                "1888e8b115c73b5ea7f33d48d9887845"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrolling.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListScrolling.kt",
+                "a32b856a1e8740a6a521df04c9d51ed1"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrollPosition.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt",
+                "82df7d370ba5b20309e5191a0af431a0"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyListState.kt",
+                "45821a5bf14d3e6e25fee63e61930f57"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt",
+                "78b09b4d78ec9d761274b9ca8d24f4f7"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItemProvider.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt",
+                "bec4211cb3d91bb936e9f0872864244b"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazySemantics.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazySemantics.kt",
+                "739205f656bf107604ba7167e3cee7e7"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/ItemIndex.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/ItemIndex.kt",
+                "1031b8b91a81c684b3c4584bc93d3fb0"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridDsl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt",
+                "6a0b2db56ef38fb1ac004e4fc9847db8"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemInfo.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridItemInfo.kt",
+                "1f3b13ee45de79bc67ace4133e634600"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt",
+                "0bbc162aab675ca2a34350e3044433e7"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScopeImpl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridItemScopeImpl.kt",
+                "b3ff4600791c73028b8661c0e2b49110"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScope.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridItemScope.kt",
+                "1a40313cc5e67b5808586c012bbfb058"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProviderImpl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridItemsProviderImpl.kt",
+                "48fdfb1dfa5d39c88d4aa96732192421"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt",
+                "e5e95e6cad43cec2b0c30bf201e3cae9"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGrid.kt",
+                "69dbab5e83deab809219d4d7a9ee7fa8"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridLayoutInfo.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridLayoutInfo.kt",
+                "b421c5e74856a78982efe0d8a79d10cb"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt",
+                "2b38f5261ad092d9048cfc4f0a841a1a"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasureResult.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridMeasureResult.kt",
+                "1277598d36d8507d7bf0305cc629a11c"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScopeImpl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridScopeImpl.kt",
+                "3296c6edcbd56450ba919df105cb36c0"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScopeMarker.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridScopeMarker.kt",
+                "0b7ff258a80e2413f89d56ab0ef41b46"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrolling.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridScrolling.kt",
+                "15f2f9bb89c1603aa4b7e7d1f8a2de5a"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrollPosition.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt",
+                "9b3d47322ad526fb17a3d9505a80f673"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpan.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridSpan.kt",
+                "cc63cb4f05cc556e8fcf7504ac0ea57c"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt",
+                "894b9f69a27e247bbe609bdac22bb5ed"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyGridState.kt",
+                "1e37d8a6f159aabe11f488121de59b70"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredItem.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt",
+                "09d9b21d33325a94cac738aad58e2422"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredItemProvider.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt",
+                "3acdfddfd06eb17aac5dbdd326482e35"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredLine.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt",
+                "1104f01e8b1f6eced2401b207114f4a4"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredLineProvider.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt",
+                "b7b731e6e8fdc520064aaef989575bda"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazySemantics.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/grid/LazySemantics.kt",
+                "dab277484b4ec57a5275095b505f79d4"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/list/LazyDsl.kt",
+                "8462c0a61f14639f39dd6f76c6a2aebc"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListPinningModifier.kt",
+                "src/commonMain/kotlin/androidx/tv/foundation/lazy/LazyListPinningModifier.kt",
+                "e37450505d13ab0fd1833f136ec8aa3c"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyScopeMarker.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeMarker.kt",
+                "f7b72b3c6bad88868153300b9fbdd922"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScope.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt",
+                "6254294540cfadf2d6da1bbbce1611e8"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemInfo.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt",
+                "7571daa18ca079fd6de31d37c3022574"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListLayoutInfo.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListLayoutInfo.kt",
+                "fa1dffc993bdc486e0819c5d8018cda3"
+        )
+)
+
+task doCopiesNeedUpdate {
+    ext.genMd5 = { fileNameToHash ->
+        try {
+            MessageDigest digest = MessageDigest.getInstance("MD5")
+            file(fileNameToHash).withInputStream() { is ->
+                byte[] buffer = new byte[8192]
+                int read
+                while ((read = is.read(buffer)) > 0) {
+                    digest.update(buffer, 0, read);
+                }
+            }
+            byte[] md5sum = digest.digest()
+            BigInteger bigInt = new BigInteger(1, md5sum)
+            bigInt.toString(16).padLeft(32, '0')
+        } catch (Exception e) {
+            throw new GradleException("Failed for file=$fileNameToHash", e)
+        }
+    }
+
+
+    doLast {
+        List<String> failureFiles = new ArrayList<>()
+        copiedClasses.forEach(copiedClass -> {
+            try {
+                String actualMd5 = genMd5(copiedClass.originalFilePath)
+                if (copiedClass.lastKnownGoodHash != actualMd5) {
+                    failureFiles.add(copiedClass.toString()+ ", actual=" + actualMd5)
+                }
+            } catch (Exception e) {
+                throw new GradleException("Failed for file=${copiedClass.originalFilePath}", e)
+            }
+        })
+
+        if (!failureFiles.isEmpty()) {
+            throw new GradleException(
+                    "Files that were copied have been updated at the source. " +
+                            "Please update the copy and then" +
+                            " update the hash in the compose-foundation build.gradle file." +
+                            failureFiles.stream().collect(Collectors.joining("\n", "\n", "")))
+        }
+    }
+}
+
+class CopiedClass {
+    String originalFilePath
+    String copyFilePath
+    String lastKnownGoodHash
+
+    CopiedClass(String originalFilePath, String copyFilePath, String lastKnownGoodHash) {
+        this.originalFilePath = originalFilePath
+        this.copyFilePath = copyFilePath
+        this.lastKnownGoodHash = lastKnownGoodHash
+    }
+
+    @Override
+    String toString() {
+        return "originalFilePath='" + originalFilePath + '\'' +
+                ", copyFilePath='" + copyFilePath + '\'' +
+                ", lastKnownGoodHash='" + lastKnownGoodHash + '\''
+    }
 }
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/AutoTestFrameClock.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/AutoTestFrameClock.kt
new file mode 100644
index 0000000..19e2105
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/AutoTestFrameClock.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2022 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 androidx.tv.compose.foundation.lazy
+
+import androidx.compose.runtime.MonotonicFrameClock
+import java.util.concurrent.atomic.AtomicLong
+
+class AutoTestFrameClock : MonotonicFrameClock {
+    private val time = AtomicLong(0)
+
+    override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
+        return onFrame(time.getAndAdd(16_000_000))
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt
new file mode 100644
index 0000000..5bf00b4
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/BaseLazyGridTestWithOrientation.kt
@@ -0,0 +1,247 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.animation.core.snap
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.testutils.assertIsEqualTo
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+
+open class BaseLazyGridTestWithOrientation(private val orientation: Orientation) {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    val vertical: Boolean
+        get() = orientation == Orientation.Vertical
+
+    @Stable
+    fun Modifier.crossAxisSize(size: Dp) =
+        if (vertical) {
+            this.width(size)
+        } else {
+            this.height(size)
+        }
+
+    @Stable
+    fun Modifier.mainAxisSize(size: Dp) =
+        if (vertical) {
+            this.height(size)
+        } else {
+            this.width(size)
+        }
+
+    @Stable
+    fun Modifier.axisSize(crossAxis: Dp, mainAxis: Dp) =
+        if (vertical) {
+            this.size(crossAxis, mainAxis)
+        } else {
+            this.size(mainAxis, crossAxis)
+        }
+
+    fun SemanticsNodeInteraction.assertMainAxisSizeIsEqualTo(expectedSize: Dp) =
+        if (vertical) {
+            assertHeightIsEqualTo(expectedSize)
+        } else {
+            assertWidthIsEqualTo(expectedSize)
+        }
+
+    fun SemanticsNodeInteraction.assertCrossAxisSizeIsEqualTo(expectedSize: Dp) =
+        if (vertical) {
+            assertWidthIsEqualTo(expectedSize)
+        } else {
+            assertHeightIsEqualTo(expectedSize)
+        }
+
+    fun SemanticsNodeInteraction.assertStartPositionIsAlmost(expected: Dp) {
+        val position = if (vertical) {
+            getUnclippedBoundsInRoot().top
+        } else {
+            getUnclippedBoundsInRoot().left
+        }
+        position.assertIsEqualTo(expected, tolerance = 1.dp)
+    }
+
+    fun SemanticsNodeInteraction.assertMainAxisStartPositionInRootIsEqualTo(expectedStart: Dp) =
+        if (vertical) {
+            assertTopPositionInRootIsEqualTo(expectedStart)
+        } else {
+            assertLeftPositionInRootIsEqualTo(expectedStart)
+        }
+
+    fun SemanticsNodeInteraction.assertCrossAxisStartPositionInRootIsEqualTo(expectedStart: Dp) =
+        if (vertical) {
+            assertLeftPositionInRootIsEqualTo(expectedStart)
+        } else {
+            assertTopPositionInRootIsEqualTo(expectedStart)
+        }
+
+    fun PaddingValues(
+        mainAxis: Dp = 0.dp,
+        crossAxis: Dp = 0.dp
+    ) = PaddingValues(
+        beforeContent = mainAxis,
+        afterContent = mainAxis,
+        beforeContentCrossAxis = crossAxis,
+        afterContentCrossAxis = crossAxis
+    )
+
+    fun PaddingValues(
+        beforeContent: Dp = 0.dp,
+        afterContent: Dp = 0.dp,
+        beforeContentCrossAxis: Dp = 0.dp,
+        afterContentCrossAxis: Dp = 0.dp,
+    ) = if (vertical) {
+        PaddingValues(
+            start = beforeContentCrossAxis,
+            top = beforeContent,
+            end = afterContentCrossAxis,
+            bottom = afterContent
+        )
+    } else {
+        PaddingValues(
+            start = beforeContent,
+            top = beforeContentCrossAxis,
+            end = afterContent,
+            bottom = afterContentCrossAxis
+        )
+    }
+
+    fun TvLazyGridState.scrollBy(offset: Dp) {
+        runBlocking(Dispatchers.Main) {
+            animateScrollBy(with(rule.density) { offset.roundToPx().toFloat() }, snap())
+        }
+    }
+
+    fun TvLazyGridState.scrollTo(index: Int) {
+        runBlocking(Dispatchers.Main) {
+            scrollToItem(index)
+        }
+    }
+
+    fun ComposeContentTestRule.keyPress(numberOfPresses: Int = 1) {
+        rule.keyPress(
+            if (vertical) NativeKeyEvent.KEYCODE_DPAD_DOWN else NativeKeyEvent.KEYCODE_DPAD_RIGHT,
+            numberOfPresses
+        )
+    }
+
+    @Composable
+    fun LazyGrid(
+        cells: Int,
+        modifier: Modifier = Modifier,
+        state: TvLazyGridState = rememberLazyGridState(),
+        contentPadding: PaddingValues = PaddingValues(0.dp),
+        reverseLayout: Boolean = false,
+        userScrollEnabled: Boolean = true,
+        crossAxisSpacedBy: Dp = 0.dp,
+        mainAxisSpacedBy: Dp = 0.dp,
+        content: TvLazyGridScope.() -> Unit
+    ) = LazyGrid(
+        TvGridCells.Fixed(cells),
+        modifier,
+        state,
+        contentPadding,
+        reverseLayout,
+        userScrollEnabled,
+        crossAxisSpacedBy,
+        mainAxisSpacedBy,
+        content
+    )
+
+    @Composable
+    fun LazyGrid(
+        cells: TvGridCells,
+        modifier: Modifier = Modifier,
+        state: TvLazyGridState = rememberLazyGridState(),
+        contentPadding: PaddingValues = PaddingValues(0.dp),
+        reverseLayout: Boolean = false,
+        userScrollEnabled: Boolean = true,
+        crossAxisSpacedBy: Dp = 0.dp,
+        mainAxisSpacedBy: Dp = 0.dp,
+        content: TvLazyGridScope.() -> Unit
+    ) {
+        if (vertical) {
+            val verticalArrangement = when {
+                mainAxisSpacedBy != 0.dp -> Arrangement.spacedBy(mainAxisSpacedBy)
+                !reverseLayout -> Arrangement.Top
+                else -> Arrangement.Bottom
+            }
+            val horizontalArrangement = when {
+                crossAxisSpacedBy != 0.dp -> Arrangement.spacedBy(crossAxisSpacedBy)
+                else -> Arrangement.Start
+            }
+            TvLazyVerticalGrid(
+                columns = cells,
+                modifier = modifier,
+                state = state,
+                contentPadding = contentPadding,
+                reverseLayout = reverseLayout,
+                userScrollEnabled = userScrollEnabled,
+                verticalArrangement = verticalArrangement,
+                horizontalArrangement = horizontalArrangement,
+                pivotOffsets = PivotOffsets(parentFraction = 0f),
+                content = content
+            )
+        } else {
+            val horizontalArrangement = when {
+                mainAxisSpacedBy != 0.dp -> Arrangement.spacedBy(mainAxisSpacedBy)
+                !reverseLayout -> Arrangement.Start
+                else -> Arrangement.End
+            }
+            val verticalArrangement = when {
+                crossAxisSpacedBy != 0.dp -> Arrangement.spacedBy(crossAxisSpacedBy)
+                else -> Arrangement.Top
+            }
+            TvLazyHorizontalGrid(
+                rows = cells,
+                modifier = modifier,
+                state = state,
+                contentPadding = contentPadding,
+                reverseLayout = reverseLayout,
+                userScrollEnabled = userScrollEnabled,
+                horizontalArrangement = horizontalArrangement,
+                verticalArrangement = verticalArrangement,
+                pivotOffsets = PivotOffsets(parentFraction = 0f),
+                content = content
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyArrangementsTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyArrangementsTest.kt
new file mode 100644
index 0000000..0ed6481
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyArrangementsTest.kt
@@ -0,0 +1,617 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.list.setContentWithTestViewConfiguration
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class LazyArrangementsTest {
+
+    private val ContainerTag = "ContainerTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var itemSize: Dp = Dp.Infinity
+    private var smallerItemSize: Dp = Dp.Infinity
+    private var containerSize: Dp = Dp.Infinity
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSize = 50.toDp()
+        }
+        with(rule.density) {
+            smallerItemSize = 40.toDp()
+        }
+        containerSize = itemSize * 5
+    }
+
+    // cases when we have not enough items to fill min constraints:
+
+    @Test
+    fun vertical_defaultArrangementIsTop() {
+        rule.setContent {
+            TvLazyVerticalGrid(
+                modifier = Modifier.requiredSize(containerSize),
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Top)
+    }
+
+    @Test
+    fun vertical_centerArrangement() {
+        composeVerticalGridWith(Arrangement.Center)
+        assertArrangementForTwoItems(Arrangement.Center)
+    }
+
+    @Test
+    fun vertical_bottomArrangement() {
+        composeVerticalGridWith(Arrangement.Bottom)
+        assertArrangementForTwoItems(Arrangement.Bottom)
+    }
+
+    @Test
+    fun vertical_spacedArrangementNotFillingViewport() {
+        val arrangement = Arrangement.spacedBy(10.dp)
+        composeVerticalGridWith(arrangement)
+        assertArrangementForTwoItems(arrangement)
+    }
+
+    @Test
+    fun horizontal_defaultArrangementIsStart() {
+        rule.setContent {
+            TvLazyHorizontalGrid(
+                modifier = Modifier.requiredSize(containerSize),
+                rows = TvGridCells.Fixed(1)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Start, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun horizontal_centerArrangement() {
+        composeHorizontalWith(Arrangement.Center, LayoutDirection.Ltr)
+        assertArrangementForTwoItems(Arrangement.Center, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun horizontal_endArrangement() {
+        composeHorizontalWith(Arrangement.End, LayoutDirection.Ltr)
+        assertArrangementForTwoItems(Arrangement.End, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun horizontal_spacedArrangementNotFillingViewport() {
+        val arrangement = Arrangement.spacedBy(10.dp)
+        composeHorizontalWith(arrangement, LayoutDirection.Ltr)
+        assertArrangementForTwoItems(arrangement, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun horizontal_rtl_startArrangement() {
+        composeHorizontalWith(Arrangement.Center, LayoutDirection.Rtl)
+        assertArrangementForTwoItems(Arrangement.Center, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun horizontal_rtl_endArrangement() {
+        composeHorizontalWith(Arrangement.End, LayoutDirection.Rtl)
+        assertArrangementForTwoItems(Arrangement.End, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun horizontal_rtl_spacedArrangementNotFillingViewport() {
+        val arrangement = Arrangement.spacedBy(10.dp)
+        composeHorizontalWith(arrangement, LayoutDirection.Rtl)
+        assertArrangementForTwoItems(arrangement, LayoutDirection.Rtl)
+    }
+
+    // wrap content and spacing
+
+    @Test
+    fun vertical_spacing_affects_wrap_content() {
+        rule.setContent {
+            TvLazyVerticalGrid(
+                verticalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.width(itemSize).testTag(ContainerTag),
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertWidthIsEqualTo(itemSize)
+            .assertHeightIsEqualTo(itemSize * 3)
+    }
+
+    @Test
+    fun horizontal_spacing_affects_wrap_content() {
+        rule.setContent {
+            TvLazyHorizontalGrid(
+                horizontalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.height(itemSize).testTag(ContainerTag),
+                rows = TvGridCells.Fixed(1)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertWidthIsEqualTo(itemSize * 3)
+            .assertHeightIsEqualTo(itemSize)
+    }
+
+    // spacing added when we have enough items to fill the viewport
+
+    @Test
+    fun vertical_spacing_scrolledToTheTop() {
+        rule.setContent {
+            TvLazyVerticalGrid(
+                verticalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f),
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2)
+    }
+
+    @Test
+    fun vertical_spacing_scrolledToTheBottom() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                verticalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f).testTag(ContainerTag),
+                columns = TvGridCells.Fixed(1),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 3)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize * 0.5f)
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2.5f)
+    }
+
+    @Test
+    fun horizontal_spacing_scrolledToTheStart() {
+        rule.setContent {
+            TvLazyHorizontalGrid(
+                horizontalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f),
+                rows = TvGridCells.Fixed(1)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 2)
+    }
+
+    @Test
+    fun horizontal_spacing_scrolledToTheEnd() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyHorizontalGrid(
+                horizontalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f).testTag(ContainerTag),
+                rows = TvGridCells.Fixed(1),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 3)
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 0.5f)
+
+        rule.onNodeWithTag("2")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 2.5f)
+    }
+
+    @Test
+    fun vertical_scrollingByExactlyTheItemSizePlusSpacer_switchesTheFirstVisibleItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                modifier = Modifier.size(itemSize * 3),
+                state = rememberLazyGridState().also { state = it },
+                verticalArrangement = Arrangement.spacedBy(spacingSize),
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it")
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun vertical_scrollingByExactlyTheItemSizePlusHalfTheSpacer_staysOnTheSameItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                modifier = Modifier.size(itemSize * 3),
+                state = rememberLazyGridState().also { state = it },
+                verticalArrangement = Arrangement.spacedBy(spacingSize),
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it")
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx / 2).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            assertThat(state.firstVisibleItemScrollOffset)
+                .isEqualTo(itemSizePx + spacingSizePx / 2)
+        }
+    }
+
+    @Test
+    fun horizontal_scrollingByExactlyTheItemSizePlusSpacer_switchesTheFirstVisibleItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyHorizontalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.size(itemSize * 3),
+                state = rememberLazyGridState().also { state = it },
+                horizontalArrangement = Arrangement.spacedBy(spacingSize)
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it")
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun horizontal_scrollingByExactlyTheItemSizePlusHalfTheSpacer_staysOnTheSameItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyHorizontalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.size(itemSize * 3),
+                state = rememberLazyGridState().also { state = it },
+                horizontalArrangement = Arrangement.spacedBy(spacingSize)
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it")
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx / 2).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            assertThat(state.firstVisibleItemScrollOffset)
+                .isEqualTo(itemSizePx + spacingSizePx / 2)
+        }
+    }
+
+    // with reverseLayout == true
+
+    @Test
+    fun vertical_defaultArrangementIsBottomWithReverseLayout() {
+        rule.setContent {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                reverseLayout = true,
+                modifier = Modifier.size(containerSize)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Bottom, reverseLayout = true)
+    }
+
+    @Test
+    fun horizontal_defaultArrangementIsEndWithReverseLayout() {
+        rule.setContent {
+            TvLazyHorizontalGrid(
+                TvGridCells.Fixed(1),
+                reverseLayout = true,
+                modifier = Modifier.requiredSize(containerSize)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(
+            Arrangement.End, LayoutDirection.Ltr, reverseLayout = true
+        )
+    }
+
+    @Test
+    fun vertical_whenArrangementChanges() {
+        var arrangement by mutableStateOf(Arrangement.Top)
+        rule.setContent {
+            TvLazyVerticalGrid(
+                modifier = Modifier.requiredSize(containerSize),
+                verticalArrangement = arrangement,
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Top)
+
+        rule.runOnIdle {
+            arrangement = Arrangement.Bottom
+        }
+
+        assertArrangementForTwoItems(Arrangement.Bottom)
+    }
+
+    @Test
+    fun horizontal_whenArrangementChanges() {
+        var arrangement by mutableStateOf(Arrangement.Start)
+        rule.setContent {
+            TvLazyHorizontalGrid(
+                TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(containerSize),
+                horizontalArrangement = arrangement
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Start, LayoutDirection.Ltr)
+
+        rule.runOnIdle {
+            arrangement = Arrangement.End
+        }
+
+        assertArrangementForTwoItems(Arrangement.End, LayoutDirection.Ltr)
+    }
+
+    fun composeVerticalGridWith(arrangement: Arrangement.Vertical) {
+        rule.setContent {
+            TvLazyVerticalGrid(
+                verticalArrangement = arrangement,
+                modifier = Modifier.requiredSize(containerSize),
+                columns = TvGridCells.Fixed(1)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+    }
+
+    fun composeHorizontalWith(
+        arrangement: Arrangement.Horizontal,
+        layoutDirection: LayoutDirection
+    ) {
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
+                TvLazyHorizontalGrid(
+                    horizontalArrangement = arrangement,
+                    modifier = Modifier.requiredSize(containerSize),
+                    rows = TvGridCells.Fixed(1)
+                ) {
+                    items(2) {
+                        Item(it)
+                    }
+                }
+            }
+        }
+    }
+
+    @Composable
+    fun Item(index: Int) {
+        require(index < 2)
+        val size = if (index == 0) itemSize else smallerItemSize
+        Box(Modifier.requiredSize(size).testTag(index.toString()))
+    }
+
+    fun assertArrangementForTwoItems(
+        arrangement: Arrangement.Vertical,
+        reverseLayout: Boolean = false
+    ) {
+        with(rule.density) {
+            val sizes = IntArray(2) {
+                val index = if (reverseLayout) if (it == 0) 1 else 0 else it
+                if (index == 0) itemSize.roundToPx() else smallerItemSize.roundToPx()
+            }
+            val outPositions = IntArray(2) { 0 }
+            with(arrangement) { arrange(containerSize.roundToPx(), sizes, outPositions) }
+
+            outPositions.forEachIndexed { index, position ->
+                val realIndex = if (reverseLayout) if (index == 0) 1 else 0 else index
+                rule.onNodeWithTag("$realIndex")
+                    .assertTopPositionInRootIsEqualTo(position.toDp())
+            }
+        }
+    }
+
+    fun assertArrangementForTwoItems(
+        arrangement: Arrangement.Horizontal,
+        layoutDirection: LayoutDirection,
+        reverseLayout: Boolean = false
+    ) {
+        with(rule.density) {
+            val sizes = IntArray(2) {
+                val index = if (reverseLayout) if (it == 0) 1 else 0 else it
+                if (index == 0) itemSize.roundToPx() else smallerItemSize.roundToPx()
+            }
+            val outPositions = IntArray(2) { 0 }
+            with(arrangement) {
+                arrange(containerSize.roundToPx(), sizes, layoutDirection, outPositions)
+            }
+
+            outPositions.forEachIndexed { index, position ->
+                val realIndex = if (reverseLayout) if (index == 0) 1 else 0 else index
+                val expectedPosition = position.toDp()
+                rule.onNodeWithTag("$realIndex")
+                    .assertLeftPositionInRootIsEqualTo(expectedPosition)
+            }
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyCustomKeysTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyCustomKeysTest.kt
new file mode 100644
index 0000000..0f444fa
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyCustomKeysTest.kt
@@ -0,0 +1,466 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazyCustomKeysTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    val itemSize = with(rule.density) {
+        100.toDp()
+    }
+    val columns = 2
+
+    @Test
+    fun itemsWithKeysAreLaidOutCorrectly() {
+        val list = listOf(MyClass(0), MyClass(1), MyClass(2))
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                items(list, key = { it.id }) {
+                    Item("${it.id}")
+                }
+            }
+        }
+
+        assertItems("0", "1", "2")
+    }
+
+    @Test
+    fun removing_statesAreMoved() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(list[0], list[2])
+        }
+
+        assertItems("0", "2")
+    }
+
+    @Test
+    fun reordering_statesAreMoved_list() {
+        testReordering { grid ->
+            items(grid, key = { it.id }) {
+                Item(remember { "${it.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_list_indexed() {
+        testReordering { grid ->
+            itemsIndexed(grid, key = { _, item -> item.id }) { _, item ->
+                Item(remember { "${item.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_array() {
+        testReordering { grid ->
+            val array = grid.toTypedArray()
+            items(array, key = { it.id }) {
+                Item(remember { "${it.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_array_indexed() {
+        testReordering { grid ->
+            val array = grid.toTypedArray()
+            itemsIndexed(array, key = { _, item -> item.id }) { _, item ->
+                Item(remember { "${item.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_itemsWithCount() {
+        testReordering { grid ->
+            items(grid.size, key = { grid[it].id }) {
+                Item(remember { "${grid[it].id}" })
+            }
+        }
+    }
+
+    @Test
+    fun fullyReplacingTheList() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        var counter = 0
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                items(list, key = { it.id }) {
+                    Item(remember { counter++ }.toString())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(MyClass(3), MyClass(4), MyClass(5), MyClass(6))
+        }
+
+        assertItems("3", "4", "5", "6")
+    }
+
+    @Test
+    fun keepingOneItem() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        var counter = 0
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                items(list, key = { it.id }) {
+                    Item(remember { counter++ }.toString())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(MyClass(1))
+        }
+
+        assertItems("1")
+    }
+
+    @Test
+    fun keepingOneItemAndAddingMore() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        var counter = 0
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                items(list, key = { it.id }) {
+                    Item(remember { counter++ }.toString())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(MyClass(1), MyClass(3))
+        }
+
+        assertItems("1", "3")
+    }
+
+    @Test
+    fun mixingKeyedItemsAndNot() {
+        testReordering { list ->
+            item {
+                Item("${list.first().id}")
+            }
+            items(list.subList(fromIndex = 1, toIndex = list.size), key = { it.id }) {
+                Item(remember { "${it.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun updatingTheDataSetIsCorrectlyApplied() {
+        val state = mutableStateOf(emptyList<Int>())
+
+        rule.setContent {
+            LaunchedEffect(Unit) {
+                state.value = listOf(4, 1, 3)
+            }
+
+            val list = state.value
+
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), Modifier.fillMaxSize()) {
+                items(list, key = { it }) {
+                    Item(it.toString())
+                }
+            }
+        }
+
+        assertItems("4", "1", "3")
+
+        rule.runOnIdle {
+            state.value = listOf(2, 4, 6, 1, 3, 5)
+        }
+
+        assertItems("2", "4", "6", "1", "3", "5")
+    }
+
+    @Test
+    fun reordering_usingMutableStateListOf() {
+        val list = mutableStateListOf(MyClass(0), MyClass(1), MyClass(2))
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list.add(list.removeAt(1))
+        }
+
+        assertItems("0", "2", "1")
+    }
+
+    @Test
+    fun keysInLazyListItemInfoAreCorrect() {
+        val list = listOf(MyClass(0), MyClass(1), MyClass(2))
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), state = state) {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(0, 1, 2))
+        }
+    }
+
+    @Test
+    fun keysInLazyListItemInfoAreCorrectAfterReordering() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(columns = TvGridCells.Fixed(columns), state = state) {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(list[0], list[2], list[1])
+        }
+
+        rule.runOnIdle {
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(0, 2, 1))
+        }
+    }
+
+    @Test
+    fun addingItemsBeforeWithoutKeysIsMaintainingTheIndex() {
+        var list by mutableStateOf((10..15).toList())
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), Modifier.size(itemSize * 2.5f), state) {
+                items(list) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..15).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun addingItemsBeforeKeepingThisItemFirst() {
+        var list by mutableStateOf((10..15).toList())
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), Modifier.size(itemSize * 2.5f), state) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..15).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(10)
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(10, 11, 12, 13, 14, 15))
+        }
+    }
+
+    @Test
+    fun addingItemsRightAfterKeepingThisItemFirst() {
+        var list by mutableStateOf((0..5).toList() + (10..15).toList())
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState(5)
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), Modifier.size(itemSize * 2.5f), state) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..15).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(4)
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(4, 5, 6, 7, 8, 9))
+        }
+    }
+
+    @Test
+    fun addingItemsBeforeWhileCurrentItemIsNotInTheBeginning() {
+        var list by mutableStateOf((10..30).toList())
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState(10) // key 20 is the first item
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), Modifier.size(itemSize * 2.5f), state) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..30).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(20)
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(20, 21, 22, 23, 24, 25))
+        }
+    }
+
+    @Test
+    fun removingTheCurrentItemMaintainsTheIndex() {
+        var list by mutableStateOf((0..20).toList())
+        lateinit var state: TvLazyGridState
+
+        rule.setContent {
+            state = rememberLazyGridState(8)
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns), Modifier.size(itemSize * 2.5f), state) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..20) - 8
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(8)
+            assertThat(state.visibleKeys).isEqualTo(listOf(9, 10, 11, 12, 13, 14))
+        }
+    }
+
+    private fun testReordering(content: TvLazyGridScope.(List<MyClass>) -> Unit) {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(columns)) {
+                content(list)
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(list[0], list[2], list[1])
+        }
+
+        assertItems("0", "2", "1")
+    }
+
+    private fun assertItems(vararg tags: String) {
+        var currentTop = 0.dp
+        var column = 0
+        tags.forEach {
+            rule.onNodeWithTag(it)
+                .assertTopPositionInRootIsEqualTo(currentTop)
+                .assertHeightIsEqualTo(itemSize)
+            ++column
+            if (column == columns) {
+                currentTop += itemSize
+                column = 0
+            }
+        }
+    }
+
+    @Composable
+    private fun Item(tag: String) {
+        Spacer(
+            Modifier.testTag(tag).size(itemSize)
+        )
+    }
+
+    private class MyClass(val id: Int)
+}
+
+val TvLazyGridState.visibleKeys: List<Any> get() = layoutInfo.visibleItemsInfo.map { it.key }
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt
new file mode 100644
index 0000000..d966436
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt
@@ -0,0 +1,1348 @@
+/*
+ * Copyright 2022 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredHeightIn
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.requiredWidthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.isSpecified
+import androidx.compose.ui.unit.width
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runners.Parameterized
+import kotlin.math.roundToInt
+import kotlinx.coroutines.runBlocking
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalFoundationApi::class)
+@LargeTest
+@RunWith(Parameterized::class)
+class LazyGridAnimateItemPlacementTest(private val config: Config) {
+
+    private val isVertical: Boolean get() = config.isVertical
+    private val reverseLayout: Boolean get() = config.reverseLayout
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val itemSize: Int = 50
+    private var itemSizeDp: Dp = Dp.Infinity
+    private val itemSize2: Int = 30
+    private var itemSize2Dp: Dp = Dp.Infinity
+    private val itemSize3: Int = 20
+    private var itemSize3Dp: Dp = Dp.Infinity
+    private val containerSize: Int = itemSize * 5
+    private var containerSizeDp: Dp = Dp.Infinity
+    private val spacing: Int = 10
+    private var spacingDp: Dp = Dp.Infinity
+    private val itemSizePlusSpacing = itemSize + spacing
+    private var itemSizePlusSpacingDp = Dp.Infinity
+    private lateinit var state: TvLazyGridState
+
+    @Before
+    fun before() {
+        rule.mainClock.autoAdvance = false
+        with(rule.density) {
+            itemSizeDp = itemSize.toDp()
+            itemSize2Dp = itemSize2.toDp()
+            itemSize3Dp = itemSize3.toDp()
+            containerSizeDp = containerSize.toDp()
+            spacingDp = spacing.toDp()
+            itemSizePlusSpacingDp = itemSizePlusSpacing.toDp()
+        }
+    }
+
+    @Test
+    fun reorderTwoItems() {
+        var list by mutableStateOf(listOf(0, 1))
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(0, itemSize)
+        )
+
+        rule.runOnIdle {
+            list = listOf(1, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, 0 + (itemSize * fraction).roundToInt()),
+                1 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun reorderTwoByTwoItems() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3))
+        rule.setContent {
+            LazyGrid(2) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(itemSize, 0),
+            2 to AxisIntOffset(0, itemSize),
+            3 to AxisIntOffset(itemSize, itemSize)
+        )
+
+        rule.runOnIdle {
+            list = listOf(3, 2, 1, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            val increasing = 0 + (itemSize * fraction).roundToInt()
+            val decreasing = itemSize - (itemSize * fraction).roundToInt()
+            assertPositions(
+                0 to AxisIntOffset(increasing, increasing),
+                1 to AxisIntOffset(decreasing, increasing),
+                2 to AxisIntOffset(increasing, decreasing),
+                3 to AxisIntOffset(decreasing, decreasing),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun reorderTwoItems_layoutInfoHasFinalPositions() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3))
+        rule.setContent {
+            LazyGrid(2) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertLayoutInfoPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(itemSize, 0),
+            2 to AxisIntOffset(0, itemSize),
+            3 to AxisIntOffset(itemSize, itemSize)
+        )
+
+        rule.runOnIdle {
+            list = listOf(3, 2, 1, 0)
+        }
+
+        onAnimationFrame {
+            // fraction doesn't affect the offsets in layout info
+            assertLayoutInfoPositions(
+                3 to AxisIntOffset(0, 0),
+                2 to AxisIntOffset(itemSize, 0),
+                1 to AxisIntOffset(0, itemSize),
+                0 to AxisIntOffset(itemSize, itemSize)
+            )
+        }
+    }
+
+    @Test
+    fun reorderFirstAndLastItems() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(0, itemSize),
+            2 to AxisIntOffset(0, itemSize * 2),
+            3 to AxisIntOffset(0, itemSize * 3),
+            4 to AxisIntOffset(0, itemSize * 4)
+        )
+
+        rule.runOnIdle {
+            list = listOf(4, 1, 2, 3, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, 0 + (itemSize * 4 * fraction).roundToInt()),
+                1 to AxisIntOffset(0, itemSize),
+                2 to AxisIntOffset(0, itemSize * 2),
+                3 to AxisIntOffset(0, itemSize * 3),
+                4 to AxisIntOffset(0, itemSize * 4 - (itemSize * 4 * fraction).roundToInt()),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveFirstItemToEndCausingAllItemsToAnimate() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        rule.setContent {
+            LazyGrid(2) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(itemSize, 0),
+            2 to AxisIntOffset(0, itemSize),
+            3 to AxisIntOffset(itemSize, itemSize),
+            4 to AxisIntOffset(0, itemSize * 2),
+            5 to AxisIntOffset(itemSize, itemSize * 2)
+        )
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 5, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            val increasingX = 0 + (itemSize * fraction).roundToInt()
+            val decreasingX = itemSize - (itemSize * fraction).roundToInt()
+            assertPositions(
+                0 to AxisIntOffset(increasingX, 0 + (itemSize * 2 * fraction).roundToInt()),
+                1 to AxisIntOffset(decreasingX, 0),
+                2 to AxisIntOffset(increasingX, itemSize - (itemSize * fraction).roundToInt()),
+                3 to AxisIntOffset(decreasingX, itemSize),
+                4 to AxisIntOffset(increasingX, itemSize * 2 - (itemSize * fraction).roundToInt()),
+                5 to AxisIntOffset(decreasingX, itemSize * 2),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun itemSizeChangeAnimatesNextItems() {
+        var height by mutableStateOf(itemSizeDp)
+        rule.setContent {
+            LazyGrid(1, minSize = itemSizeDp * 5, maxSize = itemSizeDp * 5) {
+                items(listOf(0, 1, 2, 3), key = { it }) {
+                    Item(it, height = if (it == 1) height else itemSizeDp)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            height = itemSizeDp * 2
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        rule.onNodeWithTag("1")
+            .assertMainAxisSizeIsEqualTo(height)
+
+        onAnimationFrame { fraction ->
+            if (!reverseLayout) {
+                assertPositions(
+                    0 to AxisIntOffset(0, 0),
+                    1 to AxisIntOffset(0, itemSize),
+                    2 to AxisIntOffset(0, itemSize * 2 + (itemSize * fraction).roundToInt()),
+                    3 to AxisIntOffset(0, itemSize * 3 + (itemSize * fraction).roundToInt()),
+                    fraction = fraction,
+                    autoReverse = false
+                )
+            } else {
+                assertPositions(
+                    3 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                    2 to AxisIntOffset(0, itemSize * 2 - (itemSize * fraction).roundToInt()),
+                    1 to AxisIntOffset(0, itemSize * 3 - (itemSize * fraction).roundToInt()),
+                    0 to AxisIntOffset(0, itemSize * 4),
+                    fraction = fraction,
+                    autoReverse = false
+                )
+            }
+        }
+    }
+
+    @Test
+    fun onlyItemsWithModifierAnimates() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    Item(it, animSpec = if (it == 1 || it == 3) AnimSpec else null)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, itemSize * 4),
+                1 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                2 to AxisIntOffset(0, itemSize),
+                3 to AxisIntOffset(0, itemSize * 3 - (itemSize * fraction).roundToInt()),
+                4 to AxisIntOffset(0, itemSize * 3),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animationsWithDifferentDurations() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    val duration = if (it == 1 || it == 3) Duration * 2 else Duration
+                    Item(it, animSpec = tween(duration.toInt(), easing = LinearEasing))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 0)
+        }
+
+        onAnimationFrame(duration = Duration * 2) { fraction ->
+            val shorterAnimFraction = (fraction * 2).coerceAtMost(1f)
+            assertPositions(
+                0 to AxisIntOffset(0, 0 + (itemSize * 4 * shorterAnimFraction).roundToInt()),
+                1 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                2 to AxisIntOffset(0, itemSize * 2 - (itemSize * shorterAnimFraction).roundToInt()),
+                3 to AxisIntOffset(0, itemSize * 3 - (itemSize * fraction).roundToInt()),
+                4 to AxisIntOffset(0, itemSize * 4 - (itemSize * shorterAnimFraction).roundToInt()),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun multipleChildrenPerItem() {
+        var list by mutableStateOf(listOf(0, 2))
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    Item(it)
+                    Item(it + 1)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(0, 0),
+            2 to AxisIntOffset(0, itemSize),
+            3 to AxisIntOffset(0, itemSize)
+        )
+
+        rule.runOnIdle {
+            list = listOf(2, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, 0 + (itemSize * fraction).roundToInt()),
+                1 to AxisIntOffset(0, 0 + (itemSize * fraction).roundToInt()),
+                2 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                3 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun multipleChildrenPerItemSomeDoNotAnimate() {
+        var list by mutableStateOf(listOf(0, 2))
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    Item(it)
+                    Item(it + 1, animSpec = null)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(2, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, 0 + (itemSize * fraction).roundToInt()),
+                1 to AxisIntOffset(0, itemSize),
+                2 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                3 to AxisIntOffset(0, 0),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animateArrangementChange() {
+        var arrangement by mutableStateOf(Arrangement.Center)
+        rule.setContent {
+            LazyGrid(
+                1,
+                arrangement = arrangement,
+                minSize = itemSizeDp * 5,
+                maxSize = itemSizeDp * 5
+            ) {
+                items(listOf(1, 2, 3), key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            1 to AxisIntOffset(0, itemSize),
+            2 to AxisIntOffset(0, itemSize * 2),
+            3 to AxisIntOffset(0, itemSize * 3),
+        )
+
+        rule.runOnIdle {
+            arrangement = Arrangement.SpaceBetween
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                1 to AxisIntOffset(0, itemSize - (itemSize * fraction).roundToInt()),
+                2 to AxisIntOffset(0, itemSize * 2),
+                3 to AxisIntOffset(0, itemSize * 3 + (itemSize * fraction).roundToInt()),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheBottomOutsideOfBounds() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
+        rule.setContent {
+            LazyGrid(2, maxSize = itemSizeDp * 3) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(itemSize, 0),
+            2 to AxisIntOffset(0, itemSize),
+            3 to AxisIntOffset(itemSize, itemSize),
+            4 to AxisIntOffset(0, itemSize * 2),
+            5 to AxisIntOffset(itemSize, itemSize * 2)
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9, 10, 11)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset = AxisIntOffset(itemSize, 0 + (itemSize * 4 * fraction).roundToInt())
+            val item8Offset =
+                AxisIntOffset(itemSize, itemSize * 4 - (itemSize * 4 * fraction).roundToInt())
+            val expected = mutableListOf<Pair<Any, IntOffset>>().apply {
+                add(0 to AxisIntOffset(0, 0))
+                if (item1Offset.mainAxis < itemSize * 3) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(2 to AxisIntOffset(0, itemSize))
+                add(3 to AxisIntOffset(itemSize, itemSize))
+                add(4 to AxisIntOffset(0, itemSize * 2))
+                add(5 to AxisIntOffset(itemSize, itemSize * 2))
+                if (item8Offset.mainAxis < itemSize * 3) {
+                    add(8 to item8Offset)
+                } else {
+                    rule.onNodeWithTag("8").assertIsNotDisplayed()
+                }
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheTopOutsideOfBounds() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
+        rule.setContent {
+            LazyGrid(2, maxSize = itemSizeDp * 3, startIndex = 6) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            6 to AxisIntOffset(0, 0),
+            7 to AxisIntOffset(itemSize, 0),
+            8 to AxisIntOffset(0, itemSize),
+            9 to AxisIntOffset(itemSize, itemSize),
+            10 to AxisIntOffset(0, itemSize * 2),
+            11 to AxisIntOffset(itemSize, itemSize * 2)
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9, 10, 11)
+        }
+
+        onAnimationFrame { fraction ->
+            val item8Offset = AxisIntOffset(0, itemSize - (itemSize * 4 * fraction).roundToInt())
+            val item1Offset = AxisIntOffset(
+                0,
+                itemSize * -3 + (itemSize * 4 * fraction).roundToInt()
+            )
+            val expected = mutableListOf<Pair<Any, IntOffset>>().apply {
+                if (item1Offset.mainAxis > -itemSize) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(6 to AxisIntOffset(0, 0))
+                add(7 to AxisIntOffset(itemSize, 0))
+                if (item8Offset.mainAxis > -itemSize) {
+                    add(8 to item8Offset)
+                } else {
+                    rule.onNodeWithTag("8").assertIsNotDisplayed()
+                }
+                add(9 to AxisIntOffset(itemSize, itemSize))
+                add(10 to AxisIntOffset(0, itemSize * 2))
+                add(11 to AxisIntOffset(itemSize, itemSize * 2))
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveFirstItemToEndCausingAllItemsToAnimate_withSpacing() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7))
+        rule.setContent {
+            LazyGrid(2, arrangement = Arrangement.spacedBy(spacingDp)) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 5, 6, 7, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            val increasingX = (fraction * itemSize).roundToInt()
+            val decreasingX = (itemSize - itemSize * fraction).roundToInt()
+            assertPositions(
+                0 to AxisIntOffset(increasingX, (itemSizePlusSpacing * 3 * fraction).roundToInt()),
+                1 to AxisIntOffset(decreasingX, 0),
+                2 to AxisIntOffset(
+                    increasingX,
+                    itemSizePlusSpacing - (itemSizePlusSpacing * fraction).roundToInt()
+                ),
+                3 to AxisIntOffset(decreasingX, itemSizePlusSpacing),
+                4 to AxisIntOffset(
+                    increasingX,
+                    itemSizePlusSpacing * 2 - (itemSizePlusSpacing * fraction).roundToInt()
+                ),
+                5 to AxisIntOffset(decreasingX, itemSizePlusSpacing * 2),
+                6 to AxisIntOffset(
+                    increasingX,
+                    itemSizePlusSpacing * 3 - (itemSizePlusSpacing * fraction).roundToInt()
+                ),
+                7 to AxisIntOffset(decreasingX, itemSizePlusSpacing * 3),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheBottomOutsideOfBounds_withSpacing() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
+        rule.setContent {
+            LazyGrid(
+                2,
+                maxSize = itemSizeDp * 3 + spacingDp * 2,
+                arrangement = Arrangement.spacedBy(spacingDp)
+            ) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(itemSize, 0),
+            2 to AxisIntOffset(0, itemSizePlusSpacing),
+            3 to AxisIntOffset(itemSize, itemSizePlusSpacing),
+            4 to AxisIntOffset(0, itemSizePlusSpacing * 2),
+            5 to AxisIntOffset(itemSize, itemSizePlusSpacing * 2)
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset = AxisIntOffset(
+                itemSize,
+                (itemSizePlusSpacing * 4 * fraction).roundToInt()
+            )
+            val item8Offset = AxisIntOffset(
+                itemSize,
+                itemSizePlusSpacing * 4 - (itemSizePlusSpacing * 4 * fraction).roundToInt()
+            )
+            val screenSize = itemSize * 3 + spacing * 2
+            val expected = mutableListOf<Pair<Any, IntOffset>>().apply {
+                add(0 to AxisIntOffset(0, 0))
+                if (item1Offset.mainAxis < screenSize) {
+                    add(1 to item1Offset)
+                }
+                add(2 to AxisIntOffset(0, itemSizePlusSpacing))
+                add(3 to AxisIntOffset(itemSize, itemSizePlusSpacing))
+                add(4 to AxisIntOffset(0, itemSizePlusSpacing * 2))
+                add(5 to AxisIntOffset(itemSize, itemSizePlusSpacing * 2))
+                if (item8Offset.mainAxis < screenSize) {
+                    add(8 to item8Offset)
+                }
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheTopOutsideOfBounds_withSpacing() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
+        rule.setContent {
+            LazyGrid(
+                2,
+                maxSize = itemSizeDp * 3 + spacingDp * 2,
+                arrangement = Arrangement.spacedBy(spacingDp),
+                startIndex = 4
+            ) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            4 to AxisIntOffset(0, 0),
+            5 to AxisIntOffset(itemSize, 0),
+            6 to AxisIntOffset(0, itemSizePlusSpacing),
+            7 to AxisIntOffset(itemSize, itemSizePlusSpacing),
+            8 to AxisIntOffset(0, itemSizePlusSpacing * 2),
+            9 to AxisIntOffset(itemSize, itemSizePlusSpacing * 2)
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 8, 2, 3, 4, 5, 6, 7, 1, 9, 10, 11)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset = AxisIntOffset(
+                0,
+                itemSizePlusSpacing * -2 + (itemSizePlusSpacing * 4 * fraction).roundToInt()
+            )
+            val item8Offset = AxisIntOffset(
+                0,
+                itemSizePlusSpacing * 2 - (itemSizePlusSpacing * 4 * fraction).roundToInt()
+            )
+            val expected = mutableListOf<Pair<Any, IntOffset>>().apply {
+                if (item1Offset.mainAxis > -itemSize) {
+                    add(1 to item1Offset)
+                }
+                add(4 to AxisIntOffset(0, 0))
+                add(5 to AxisIntOffset(itemSize, 0))
+                add(6 to AxisIntOffset(0, itemSizePlusSpacing))
+                add(7 to AxisIntOffset(itemSize, itemSizePlusSpacing))
+                if (item8Offset.mainAxis > -itemSize) {
+                    add(8 to item8Offset)
+                }
+                add(9 to AxisIntOffset(itemSize, itemSizePlusSpacing * 2))
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheTopOutsideOfBounds_differentSizes() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
+        rule.setContent {
+            LazyGrid(2, maxSize = itemSize2Dp + itemSize3Dp + itemSizeDp, startIndex = 6) {
+                items(list, key = { it }) {
+                    val height = when (it) {
+                        2 -> itemSize3Dp
+                        3 -> itemSize3Dp / 2
+                        6 -> itemSize2Dp
+                        7 -> itemSize2Dp / 2
+                        else -> {
+                            if (it % 2 == 0) itemSizeDp else itemSize3Dp / 2
+                        }
+                    }
+                    Item(it, height = height)
+                }
+            }
+        }
+
+        val line3Size = itemSize2
+        val line4Size = itemSize
+        assertPositions(
+            6 to AxisIntOffset(0, 0),
+            7 to AxisIntOffset(itemSize, 0),
+            8 to AxisIntOffset(0, line3Size),
+            9 to AxisIntOffset(itemSize, line3Size),
+            10 to AxisIntOffset(0, line3Size + line4Size),
+            11 to AxisIntOffset(itemSize, line3Size + line4Size)
+        )
+
+        rule.runOnIdle {
+            // swap 8 and 2
+            list = listOf(0, 1, 8, 3, 4, 5, 6, 7, 2, 9, 10, 11)
+        }
+
+        onAnimationFrame { fraction ->
+            rule.onNodeWithTag("4").assertDoesNotExist()
+            rule.onNodeWithTag("5").assertDoesNotExist()
+            // items 4,5 were between lines 1 and 3 but we don't compose them and don't know the
+            // real size, so we use an average size.
+            val line2Size = (itemSize + itemSize2 + itemSize3) / 3
+            val line1Size = itemSize3 /* the real size of the item 2 */
+            val startItem2Offset = -line1Size - line2Size
+            val item2Offset =
+                startItem2Offset + ((itemSize2 - startItem2Offset) * fraction).roundToInt()
+            val endItem8Offset = -line2Size - itemSize
+            val item8Offset = line3Size - ((line3Size - endItem8Offset) * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, IntOffset>>().apply {
+                if (item8Offset > -line4Size) {
+                    add(8 to AxisIntOffset(0, item8Offset))
+                } else {
+                    rule.onNodeWithTag("8").assertIsNotDisplayed()
+                }
+                add(6 to AxisIntOffset(0, 0))
+                add(7 to AxisIntOffset(itemSize, 0))
+                if (item2Offset > -line1Size) {
+                    add(2 to AxisIntOffset(0, item2Offset))
+                } else {
+                    rule.onNodeWithTag("2").assertIsNotDisplayed()
+                }
+                add(9 to AxisIntOffset(itemSize, line3Size))
+                add(10 to AxisIntOffset(
+                    0,
+                    line3Size + line4Size - ((itemSize - itemSize3) * fraction).roundToInt()
+                ))
+                add(11 to AxisIntOffset(
+                    itemSize,
+                    line3Size + line4Size - ((itemSize - itemSize3) * fraction).roundToInt()
+                ))
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheBottomOutsideOfBounds_differentSizes() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11))
+        val gridSize = itemSize2 + itemSize3 + itemSize - 1
+        val gridSizeDp = with(rule.density) { gridSize.toDp() }
+        rule.setContent {
+            LazyGrid(2, maxSize = gridSizeDp) {
+                items(list, key = { it }) {
+                    val height = when (it) {
+                        0 -> itemSize2Dp
+                        8 -> itemSize3Dp
+                        else -> {
+                            if (it % 2 == 0) itemSizeDp else itemSize3Dp / 2
+                        }
+                    }
+                    Item(it, height = height)
+                }
+            }
+        }
+
+        val line0Size = itemSize2
+        val line1Size = itemSize
+        assertPositions(
+            0 to AxisIntOffset(0, 0),
+            1 to AxisIntOffset(itemSize, 0),
+            2 to AxisIntOffset(0, line0Size),
+            3 to AxisIntOffset(itemSize, line0Size),
+            4 to AxisIntOffset(0, line0Size + line1Size),
+            5 to AxisIntOffset(itemSize, line0Size + line1Size),
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 1, 8, 3, 4, 5, 6, 7, 2, 9, 10, 11)
+        }
+
+        onAnimationFrame { fraction ->
+            val line2Size = itemSize
+            val line4Size = itemSize3
+            // line 3 was between 2 and 4 but we don't compose it and don't know the real size,
+            // so we use an average size.
+            val line3Size = (itemSize + itemSize2 + itemSize3) / 3
+            val startItem8Offset = line0Size + line1Size + line2Size + line3Size
+            val endItem2Offset = line0Size + line4Size + line2Size + line3Size
+            val item2Offset =
+                line0Size + ((endItem2Offset - line0Size) * fraction).roundToInt()
+            val item8Offset =
+                startItem8Offset - ((startItem8Offset - line0Size) * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, IntOffset>>().apply {
+                add(0 to AxisIntOffset(0, 0))
+                add(1 to AxisIntOffset(itemSize, 0))
+                if (item8Offset < gridSize) {
+                    add(8 to AxisIntOffset(0, item8Offset))
+                } else {
+                    // rule.onNodeWithTag("8").assertIsNotDisplayed()
+                }
+                add(3 to AxisIntOffset(itemSize, line0Size))
+                add(4 to AxisIntOffset(
+                    0,
+                    line0Size + line1Size - ((line1Size - line4Size) * fraction).roundToInt()
+                ))
+                add(5 to AxisIntOffset(
+                    itemSize,
+                    line0Size + line1Size - ((line1Size - line4Size) * fraction).roundToInt()
+                ))
+                if (item2Offset < gridSize) {
+                    add(2 to AxisIntOffset(0, item2Offset))
+                } else {
+                    // rule.onNodeWithTag("2").assertIsNotDisplayed()
+                }
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    // @Test
+    // fun animateAlignmentChange() {
+    //     var alignment by mutableStateOf(CrossAxisAlignment.End)
+    //     rule.setContent {
+    //         LazyList(
+    //             crossAxisAlignment = alignment,
+    //             crossAxisSize = itemSizeDp
+    //         ) {
+    //             items(listOf(1, 2, 3), key = { it }) {
+    //                 val crossAxisSize =
+    //                     if (it == 1) itemSizeDp else if (it == 2) itemSize2Dp else itemSize3Dp
+    //                 Item(it, crossAxisSize = crossAxisSize)
+    //             }
+    //         }
+    //     }
+
+    //     val item2Start = itemSize - itemSize2
+    //     val item3Start = itemSize - itemSize3
+    //     assertPositions(
+    //         1 to 0,
+    //         2 to itemSize,
+    //         3 to itemSize * 2,
+    //         crossAxis = listOf(
+    //             1 to 0,
+    //             2 to item2Start,
+    //             3 to item3Start,
+    //         )
+    //     )
+
+    //     rule.runOnIdle {
+    //         alignment = CrossAxisAlignment.Center
+    //     }
+    //     rule.mainClock.advanceTimeByFrame()
+
+    //     val item2End = itemSize / 2 - itemSize2 / 2
+    //     val item3End = itemSize / 2 - itemSize3 / 2
+    //     onAnimationFrame { fraction ->
+    //         assertPositions(
+    //             1 to 0,
+    //             2 to itemSize,
+    //             3 to itemSize * 2,
+    //             crossAxis = listOf(
+    //                 1 to 0,
+    //                 2 to item2Start + ((item2End - item2Start) * fraction).roundToInt(),
+    //                 3 to item3Start + ((item3End - item3Start) * fraction).roundToInt(),
+    //             ),
+    //             fraction = fraction
+    //         )
+    //     }
+    // }
+
+    // @Test
+    // fun animateAlignmentChange_multipleChildrenPerItem() {
+    //     var alignment by mutableStateOf(CrossAxisAlignment.Start)
+    //     rule.setContent {
+    //         LazyList(
+    //             crossAxisAlignment = alignment,
+    //             crossAxisSize = itemSizeDp * 2
+    //         ) {
+    //             items(1) {
+    //                 listOf(1, 2, 3).forEach {
+    //                     val crossAxisSize =
+    //                         if (it == 1) itemSizeDp else if (it == 2) itemSize2Dp else itemSize3Dp
+    //                     Item(it, crossAxisSize = crossAxisSize)
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.runOnIdle {
+    //         alignment = CrossAxisAlignment.End
+    //     }
+    //     rule.mainClock.advanceTimeByFrame()
+
+    //     val containerSize = itemSize * 2
+    //     onAnimationFrame { fraction ->
+    //         assertPositions(
+    //             1 to 0,
+    //             2 to itemSize,
+    //             3 to itemSize * 2,
+    //             crossAxis = listOf(
+    //                 1 to ((containerSize - itemSize) * fraction).roundToInt(),
+    //                 2 to ((containerSize - itemSize2) * fraction).roundToInt(),
+    //                 3 to ((containerSize - itemSize3) * fraction).roundToInt()
+    //             ),
+    //             fraction = fraction
+    //         )
+    //     }
+    // }
+
+    // @Test
+    // fun animateAlignmentChange_rtl() {
+    //     // this test is not applicable to LazyRow
+    //     assumeTrue(isVertical)
+
+    //     var alignment by mutableStateOf(CrossAxisAlignment.End)
+    //     rule.setContent {
+    //         CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+    //             LazyList(
+    //                 crossAxisAlignment = alignment,
+    //                 crossAxisSize = itemSizeDp
+    //             ) {
+    //                 items(listOf(1, 2, 3), key = { it }) {
+    //                     val crossAxisSize =
+    //                         if (it == 1) itemSizeDp else if (it == 2) itemSize2Dp else itemSize3Dp
+    //                     Item(it, crossAxisSize = crossAxisSize)
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     assertPositions(
+    //         1 to 0,
+    //         2 to itemSize,
+    //         3 to itemSize * 2,
+    //         crossAxis = listOf(
+    //             1 to 0,
+    //             2 to 0,
+    //             3 to 0,
+    //         )
+    //     )
+
+    //     rule.runOnIdle {
+    //         alignment = CrossAxisAlignment.Center
+    //     }
+    //     rule.mainClock.advanceTimeByFrame()
+
+    //     onAnimationFrame { fraction ->
+    //         assertPositions(
+    //             1 to 0,
+    //             2 to itemSize,
+    //             3 to itemSize * 2,
+    //             crossAxis = listOf(
+    //                 1 to 0,
+    //                 2 to ((itemSize / 2 - itemSize2 / 2) * fraction).roundToInt(),
+    //                 3 to ((itemSize / 2 - itemSize3 / 2) * fraction).roundToInt(),
+    //             ),
+    //             fraction = fraction
+    //         )
+    //     }
+    // }
+
+    @Test
+    fun moveItemToEndCausingNextItemsToAnimate_withContentPadding() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val rawStartPadding = 8
+        val rawEndPadding = 12
+        val (startPaddingDp, endPaddingDp) = with(rule.density) {
+            rawStartPadding.toDp() to rawEndPadding.toDp()
+        }
+        rule.setContent {
+            LazyGrid(1, startPadding = startPaddingDp, endPadding = endPaddingDp) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        val startPadding = if (reverseLayout) rawEndPadding else rawStartPadding
+        assertPositions(
+            0 to AxisIntOffset(0, startPadding),
+            1 to AxisIntOffset(0, startPadding + itemSize),
+            2 to AxisIntOffset(0, startPadding + itemSize * 2),
+            3 to AxisIntOffset(0, startPadding + itemSize * 3),
+            4 to AxisIntOffset(0, startPadding + itemSize * 4),
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 2, 3, 4, 1)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, startPadding),
+                1 to AxisIntOffset(
+                    0,
+                    startPadding + itemSize + (itemSize * 3 * fraction).roundToInt()
+                ),
+                2 to AxisIntOffset(
+                    0,
+                    startPadding + itemSize * 2 - (itemSize * fraction).roundToInt()
+                ),
+                3 to AxisIntOffset(
+                    0,
+                    startPadding + itemSize * 3 - (itemSize * fraction).roundToInt()
+                ),
+                4 to AxisIntOffset(
+                    0,
+                    startPadding + itemSize * 4 - (itemSize * fraction).roundToInt()
+                ),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun reorderFirstAndLastItems_noNewLayoutInfoProduced() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+
+        var measurePasses = 0
+        rule.setContent {
+            LazyGrid(1) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+            LaunchedEffect(Unit) {
+                snapshotFlow { state.layoutInfo }
+                    .collect {
+                        measurePasses++
+                    }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(4, 1, 2, 3, 0)
+        }
+
+        var startMeasurePasses = Int.MIN_VALUE
+        onAnimationFrame { fraction ->
+            if (fraction == 0f) {
+                startMeasurePasses = measurePasses
+            }
+        }
+        rule.mainClock.advanceTimeByFrame()
+        // new layoutInfo is produced on every remeasure of Lazy lists.
+        // but we want to avoid remeasuring and only do relayout on each animation frame.
+        // two extra measures are possible as we switch inProgress flag.
+        assertThat(measurePasses).isAtMost(startMeasurePasses + 2)
+    }
+
+    @Test
+    fun noAnimationWhenScrollOtherPosition() {
+        rule.setContent {
+            LazyGrid(1, maxSize = itemSizeDp * 3) {
+                items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(0, itemSize / 2)
+            }
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to AxisIntOffset(0, -itemSize / 2),
+                1 to AxisIntOffset(0, itemSize / 2),
+                2 to AxisIntOffset(0, itemSize * 3 / 2),
+                3 to AxisIntOffset(0, itemSize * 5 / 2),
+                fraction = fraction
+            )
+        }
+    }
+
+    private fun AxisIntOffset(crossAxis: Int, mainAxis: Int) =
+        if (isVertical) IntOffset(crossAxis, mainAxis) else IntOffset(mainAxis, crossAxis)
+
+    private val IntOffset.mainAxis: Int get() = if (isVertical) y else x
+
+    private fun assertPositions(
+        vararg expected: Pair<Any, IntOffset>,
+        crossAxis: List<Pair<Any, Int>>? = null,
+        fraction: Float? = null,
+        autoReverse: Boolean = reverseLayout
+    ) {
+        with(rule.density) {
+            val actual = expected.map {
+                val actualOffset = rule.onNodeWithTag(it.first.toString())
+                    .getUnclippedBoundsInRoot().let { bounds ->
+                        IntOffset(
+                            if (bounds.left.isSpecified) bounds.left.roundToPx() else Int.MIN_VALUE,
+                            if (bounds.top.isSpecified) bounds.top.roundToPx() else Int.MIN_VALUE
+                        )
+                    }
+                it.first to actualOffset
+            }
+            val subject = if (fraction == null) {
+                assertThat(actual)
+            } else {
+                assertWithMessage("Fraction=$fraction").that(actual)
+            }
+            subject.isEqualTo(
+                listOf(*expected).let { list ->
+                    if (!autoReverse) {
+                        list
+                    } else {
+                        val containerBounds = rule.onNodeWithTag(ContainerTag).getBoundsInRoot()
+                        val containerSize = with(rule.density) {
+                            IntSize(
+                                containerBounds.width.roundToPx(),
+                                containerBounds.height.roundToPx()
+                            )
+                        }
+                        list.map {
+                            val itemSize = rule.onNodeWithTag(it.first.toString())
+                                .getUnclippedBoundsInRoot().let {
+                                    IntSize(it.width.roundToPx(), it.height.roundToPx())
+                                }
+                            it.first to
+                                IntOffset(
+                                    if (isVertical) {
+                                        it.second.x
+                                    } else {
+                                        containerSize.width - itemSize.width - it.second.x
+                                    },
+                                    if (!isVertical) {
+                                        it.second.y
+                                    } else {
+                                        containerSize.height - itemSize.height - it.second.y
+                                    }
+                                )
+                        }
+                    }
+                }
+            )
+            if (crossAxis != null) {
+                val actualCross = expected.map {
+                    val actualOffset = rule.onNodeWithTag(it.first.toString())
+                        .getUnclippedBoundsInRoot().let { bounds ->
+                            val offset = if (isVertical) bounds.left else bounds.top
+                            if (offset == Dp.Unspecified) Int.MIN_VALUE else offset.roundToPx()
+                        }
+                    it.first to actualOffset
+                }
+                assertWithMessage(
+                    "CrossAxis" + if (fraction != null) "for fraction=$fraction" else ""
+                )
+                    .that(actualCross)
+                    .isEqualTo(crossAxis)
+            }
+        }
+    }
+
+    private fun assertLayoutInfoPositions(vararg offsets: Pair<Any, IntOffset>) {
+        rule.runOnIdle {
+            assertThat(visibleItemsOffsets).isEqualTo(listOf(*offsets))
+        }
+    }
+
+    private val visibleItemsOffsets: List<Pair<Any, IntOffset>>
+        get() = state.layoutInfo.visibleItemsInfo.map {
+            it.key to it.offset
+        }
+
+    private fun onAnimationFrame(duration: Long = Duration, onFrame: (fraction: Float) -> Unit) {
+        require(duration.mod(FrameDuration) == 0L)
+        rule.waitForIdle()
+        rule.mainClock.advanceTimeByFrame()
+        var expectedTime = rule.mainClock.currentTime
+        for (i in 0..duration step FrameDuration) {
+            onFrame(i / duration.toFloat())
+            rule.mainClock.advanceTimeBy(FrameDuration)
+            expectedTime += FrameDuration
+            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            rule.waitForIdle()
+        }
+    }
+
+    @Composable
+    private fun LazyGrid(
+        columns: Int,
+        arrangement: Arrangement.HorizontalOrVertical? = null,
+        minSize: Dp = 0.dp,
+        maxSize: Dp = containerSizeDp,
+        startIndex: Int = 0,
+        startPadding: Dp = 0.dp,
+        endPadding: Dp = 0.dp,
+        content: TvLazyGridScope.() -> Unit
+    ) {
+        state = rememberLazyGridState(startIndex)
+        if (isVertical) {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(columns),
+                Modifier
+                    .requiredHeightIn(minSize, maxSize)
+                    .requiredWidth(itemSizeDp * columns)
+                    .testTag(ContainerTag),
+                state = state,
+                verticalArrangement = arrangement as? Arrangement.Vertical
+                    ?: if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(top = startPadding, bottom = endPadding),
+                content = content
+            )
+        } else {
+            TvLazyHorizontalGrid(
+                TvGridCells.Fixed(columns),
+                Modifier
+                    .requiredWidthIn(minSize, maxSize)
+                    .requiredHeight(itemSizeDp * columns)
+                    .testTag(ContainerTag),
+                state = state,
+                horizontalArrangement = arrangement as? Arrangement.Horizontal
+                    ?: if (!reverseLayout) Arrangement.Start else Arrangement.End,
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(start = startPadding, end = endPadding),
+                content = content
+            )
+        }
+    }
+
+    @Composable
+    private fun TvLazyGridItemScope.Item(
+        tag: Int,
+        height: Dp = itemSizeDp,
+        animSpec: FiniteAnimationSpec<IntOffset>? = AnimSpec
+    ) {
+        Box(
+            Modifier
+                .then(
+                    if (isVertical) {
+                        Modifier.requiredHeight(height)
+                    } else {
+                        Modifier.requiredWidth(height)
+                    }
+                )
+                .testTag(tag.toString())
+                .then(
+                    if (animSpec != null) {
+                        Modifier.animateItemPlacement(animSpec)
+                    } else {
+                        Modifier
+                    }
+                )
+        )
+    }
+
+    private fun SemanticsNodeInteraction.assertMainAxisSizeIsEqualTo(
+        expected: Dp
+    ): SemanticsNodeInteraction {
+        return if (isVertical) assertHeightIsEqualTo(expected) else assertWidthIsEqualTo(expected)
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() = arrayOf(
+            Config(isVertical = true, reverseLayout = false),
+            Config(isVertical = false, reverseLayout = false),
+            Config(isVertical = true, reverseLayout = true),
+            Config(isVertical = false, reverseLayout = true),
+        )
+
+        class Config(
+            val isVertical: Boolean,
+            val reverseLayout: Boolean
+        ) {
+            override fun toString() =
+                (if (isVertical) "LazyVerticalGrid" else "LazyHorizontalGrid") +
+                    (if (reverseLayout) "(reverse)" else "")
+        }
+    }
+}
+
+private val FrameDuration = 16L
+private val Duration = 400L
+private val AnimSpec = tween<IntOffset>(Duration.toInt(), easing = LinearEasing)
+private val ContainerTag = "container"
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
new file mode 100644
index 0000000..b2e1a5a
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
@@ -0,0 +1,397 @@
+/*
+ * Copyright 2022 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Remeasurement
+import androidx.compose.ui.layout.RemeasurementModifier
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.LargeTest
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@LargeTest
+@RunWith(Parameterized::class)
+class LazyGridPrefetcherTest(
+    orientation: Orientation
+) : BaseLazyGridTestWithOrientation(orientation) {
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun initParameters(): Array<Any> = arrayOf(
+            Orientation.Vertical,
+            Orientation.Horizontal,
+        )
+    }
+
+    val itemsSizePx = 30
+    val itemsSizeDp = with(rule.density) { itemsSizePx.toDp() }
+
+    lateinit var state: TvLazyGridState
+
+    @Test
+    fun notPrefetchingForwardInitially() {
+        composeList()
+
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun notPrefetchingBackwardInitially() {
+        composeList(firstItem = 4)
+
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAfterSmallScroll() {
+        composeList()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(4)
+
+        rule.onNodeWithTag("4")
+            .assertExists()
+        rule.onNodeWithTag("5")
+            .assertExists()
+        rule.onNodeWithTag("6")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingBackwardAfterSmallScroll() {
+        composeList(firstItem = 4, itemOffset = 10)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-5f)
+            }
+        }
+
+        waitForPrefetch(2)
+
+        rule.onNodeWithTag("2")
+            .assertExists()
+        rule.onNodeWithTag("3")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAndBackward() {
+        composeList(firstItem = 2)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(6)
+
+        rule.onNodeWithTag("6")
+            .assertExists()
+        rule.onNodeWithTag("7")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-2f)
+                state.scrollBy(-1f)
+            }
+        }
+
+        waitForPrefetch(0)
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+        rule.onNodeWithTag("1")
+            .assertExists()
+        rule.onNodeWithTag("6")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardTwice() {
+        composeList()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(4)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(itemsSizePx / 2f)
+                state.scrollBy(itemsSizePx / 2f)
+            }
+        }
+
+        waitForPrefetch(6)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("6")
+            .assertExists()
+        rule.onNodeWithTag("8")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingBackwardTwice() {
+        composeList(firstItem = 8)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-5f)
+            }
+        }
+
+        waitForPrefetch(4)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-itemsSizePx / 2f)
+                state.scrollBy(-itemsSizePx / 2f)
+            }
+        }
+
+        waitForPrefetch(2)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("6")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("2")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAndBackwardReverseLayout() {
+        composeList(firstItem = 2, reverseLayout = true)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(6)
+
+        rule.onNodeWithTag("6")
+            .assertExists()
+        rule.onNodeWithTag("7")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-2f)
+                state.scrollBy(-1f)
+            }
+        }
+
+        waitForPrefetch(0)
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+        rule.onNodeWithTag("1")
+            .assertExists()
+        rule.onNodeWithTag("6")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("7")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAndBackwardWithContentPadding() {
+        val halfItemSize = itemsSizeDp / 2f
+        composeList(
+            firstItem = 4,
+            itemOffset = 5,
+            contentPadding = PaddingValues(mainAxis = halfItemSize)
+        )
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("6")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("8")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(6)
+
+        rule.onNodeWithTag("8")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-2f)
+            }
+        }
+
+        waitForPrefetch(0)
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+    }
+
+    @Test
+    fun disposingWhilePrefetchingScheduled() {
+        var emit = true
+        lateinit var remeasure: Remeasurement
+        rule.setContent {
+            SubcomposeLayout(
+                modifier = object : RemeasurementModifier {
+                    override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
+                        remeasure = remeasurement
+                    }
+                }
+            ) { constraints ->
+                val placeable = if (emit) {
+                    subcompose(Unit) {
+                        state = rememberLazyGridState()
+                        LazyGrid(
+                            2,
+                            Modifier.mainAxisSize(itemsSizeDp * 1.5f),
+                            state,
+                        ) {
+                            items(1000) {
+                                Spacer(
+                                    Modifier.mainAxisSize(itemsSizeDp)
+                                )
+                            }
+                        }
+                    }.first().measure(constraints)
+                } else {
+                    null
+                }
+                layout(constraints.maxWidth, constraints.maxHeight) {
+                    placeable?.place(0, 0)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            // this will schedule the prefetching
+            runBlocking(AutoTestFrameClock()) {
+                state.scrollBy(itemsSizePx.toFloat())
+            }
+            // then we synchronously dispose LazyColumn
+            emit = false
+            remeasure.forceRemeasure()
+        }
+
+        rule.runOnIdle { }
+    }
+
+    private fun waitForPrefetch(index: Int) {
+        rule.waitUntil {
+            activeNodes.contains(index) && activeMeasuredNodes.contains(index)
+        }
+    }
+
+    private val activeNodes = mutableSetOf<Int>()
+    private val activeMeasuredNodes = mutableSetOf<Int>()
+
+    private fun composeList(
+        firstItem: Int = 0,
+        itemOffset: Int = 0,
+        reverseLayout: Boolean = false,
+        contentPadding: PaddingValues = PaddingValues(0.dp)
+    ) {
+        rule.setContent {
+            state = rememberLazyGridState(
+                initialFirstVisibleItemIndex = firstItem,
+                initialFirstVisibleItemScrollOffset = itemOffset
+            )
+            LazyGrid(
+                2,
+                Modifier.mainAxisSize(itemsSizeDp * 1.5f),
+                state,
+                reverseLayout = reverseLayout,
+                contentPadding = contentPadding
+            ) {
+                items(100) {
+                    DisposableEffect(it) {
+                        activeNodes.add(it)
+                        onDispose {
+                            activeNodes.remove(it)
+                            activeMeasuredNodes.remove(it)
+                        }
+                    }
+                    Spacer(
+                        Modifier
+                            .mainAxisSize(itemsSizeDp)
+                            .testTag("$it")
+                            .layout { measurable, constraints ->
+                                val placeable = measurable.measure(constraints)
+                                activeMeasuredNodes.add(it)
+                                layout(placeable.width, placeable.height) {
+                                    placeable.place(0, 0)
+                                }
+                            }
+                    )
+                }
+            }
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridSlotsReuseTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridSlotsReuseTest.kt
new file mode 100644
index 0000000..c9f6943
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridSlotsReuseTest.kt
@@ -0,0 +1,486 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class LazyGridSlotsReuseTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    val itemsSizePx = 30f
+    val itemsSizeDp = with(rule.density) { itemsSizePx.toDp() }
+
+    @Test
+    fun scroll1ItemScrolledOffItemIsKeptForReuse() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(1)
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scroll2ItemsScrolledOffItemsAreKeptForReuse() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2)
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun checkMaxItemsKeptForReuse() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * (DefaultMaxItemsToRetain + 0.5f)),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(DefaultMaxItemsToRetain + 1)
+            }
+        }
+
+        repeat(DefaultMaxItemsToRetain) {
+            rule.onNodeWithTag("$it")
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+        rule.onNodeWithTag("$DefaultMaxItemsToRetain")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("${DefaultMaxItemsToRetain + 1}")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scroll3Items2OfScrolledOffItemsAreKeptForReuse() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                // after this step 0 and 1 are in reusable buffer
+                state.scrollToItem(2)
+
+                // this step requires one item and will take the last item from the buffer - item
+                // 1 plus will put 2 in the buffer. so expected buffer is items 2 and 0
+                state.scrollToItem(3)
+            }
+        }
+
+        // recycled
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        // in buffer
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("2")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun doMultipleScrollsOneByOne() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(1) // buffer is [0]
+                state.scrollToItem(2) // 0 used, buffer is [1]
+                state.scrollToItem(3) // 1 used, buffer is [2]
+                state.scrollToItem(4) // 2 used, buffer is [3]
+            }
+        }
+
+        // recycled
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("2")
+            .assertDoesNotExist()
+
+        // in buffer
+        rule.onNodeWithTag("3")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("5")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scrollBackwardOnce() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState(10)
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(8) // buffer is [10, 11]
+            }
+        }
+
+        // in buffer
+        rule.onNodeWithTag("10")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("11")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("8")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("9")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scrollBackwardOneByOne() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState(10)
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(9) // buffer is [11]
+                state.scrollToItem(7) // 11 reused, buffer is [9]
+                state.scrollToItem(6) // 9 reused, buffer is [8]
+            }
+        }
+
+        // in buffer
+        rule.onNodeWithTag("8")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("6")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("7")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scrollingBackReusesTheSameSlot() {
+        lateinit var state: TvLazyGridState
+        var counter0 = 0
+        var counter1 = 10
+        var rememberedValue0 = -1
+        var rememberedValue1 = -1
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 1.5f),
+                state
+            ) {
+                items(100) {
+                    if (it == 0) {
+                        rememberedValue0 = remember { counter0++ }
+                    }
+                    if (it == 1) {
+                        rememberedValue1 = remember { counter1++ }
+                    }
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2) // buffer is [0, 1]
+                state.scrollToItem(0) // scrolled back, 0 and 1 are reused back. buffer: [2, 3]
+            }
+        }
+
+        rule.runOnIdle {
+            Truth.assertWithMessage("Item 0 restored remembered value is $rememberedValue0")
+                .that(rememberedValue0).isEqualTo(0)
+            Truth.assertWithMessage("Item 1 restored remembered value is $rememberedValue1")
+                .that(rememberedValue1).isEqualTo(10)
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("3")
+            .assertExists()
+            .assertIsNotDisplayed()
+    }
+
+    @Test
+    fun differentContentTypes() {
+        lateinit var state: TvLazyGridState
+        val visibleItemsCount = (DefaultMaxItemsToRetain + 1) * 2
+        val startOfType1 = DefaultMaxItemsToRetain + 1
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * (visibleItemsCount - 0.5f)),
+                state
+            ) {
+                items(
+                    100,
+                    contentType = { if (it >= startOfType1) 1 else 0 }
+                ) {
+                    Spacer(Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it"))
+                }
+            }
+        }
+
+        for (i in 0 until visibleItemsCount) {
+            rule.onNodeWithTag("$i")
+                .assertIsDisplayed()
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(visibleItemsCount)
+            }
+        }
+
+        rule.onNodeWithTag("$visibleItemsCount")
+            .assertIsDisplayed()
+
+        // [DefaultMaxItemsToRetain] items of type 0 are left for reuse
+        for (i in 0 until DefaultMaxItemsToRetain) {
+            rule.onNodeWithTag("$i")
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+        rule.onNodeWithTag("$DefaultMaxItemsToRetain")
+            .assertDoesNotExist()
+
+        // and 7 items of type 1
+        for (i in startOfType1 until startOfType1 + DefaultMaxItemsToRetain) {
+            rule.onNodeWithTag("$i")
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+        rule.onNodeWithTag("${startOfType1 + DefaultMaxItemsToRetain}")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun differentTypesFromDifferentItemCalls() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.height(itemsSizeDp * 2.5f),
+                state
+            ) {
+                val content = @Composable { tag: String ->
+                    Spacer(Modifier.height(itemsSizeDp).width(10.dp).testTag(tag))
+                }
+                item(contentType = "not-to-reuse-0") {
+                    content("0")
+                }
+                item(contentType = "reuse") {
+                    content("1")
+                }
+                items(
+                    List(100) { it + 2 },
+                    contentType = { if (it == 10) "reuse" else "not-to-reuse-$it" }) {
+                    content("$it")
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2)
+                // now items 0 and 1 are put into reusables
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(9)
+                // item 10 should reuse slot 1
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("9")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("10")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("11")
+            .assertIsDisplayed()
+    }
+}
+
+private val DefaultMaxItemsToRetain = 7
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridSpanTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridSpanTest.kt
new file mode 100644
index 0000000..3d4c09e
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridSpanTest.kt
@@ -0,0 +1,301 @@
+/*
+ * Copyright 2022 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazyGridSpanTest {
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun spans() {
+        val columns = 4
+        val columnWidth = with(rule.density) { 5.toDp() }
+        val itemHeight = with(rule.density) { 10.toDp() }
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(columns),
+                modifier = Modifier.requiredSize(columnWidth * columns, itemHeight * 3)
+            ) {
+                items(
+                    count = 6,
+                    span = { index ->
+                        when (index) {
+                            0 -> {
+                                Truth.assertThat(maxLineSpan).isEqualTo(4)
+                                Truth.assertThat(maxCurrentLineSpan).isEqualTo(4)
+                                TvGridItemSpan(3)
+                            }
+                            1 -> {
+                                Truth.assertThat(maxLineSpan).isEqualTo(4)
+                                Truth.assertThat(maxCurrentLineSpan).isEqualTo(1)
+                                TvGridItemSpan(1)
+                            }
+                            2 -> {
+                                Truth.assertThat(maxLineSpan).isEqualTo(4)
+                                Truth.assertThat(maxCurrentLineSpan).isEqualTo(4)
+                                TvGridItemSpan(1)
+                            }
+                            3 -> {
+                                Truth.assertThat(maxLineSpan).isEqualTo(4)
+                                Truth.assertThat(maxCurrentLineSpan).isEqualTo(3)
+                                TvGridItemSpan(3)
+                            }
+                            4 -> {
+                                Truth.assertThat(maxLineSpan).isEqualTo(4)
+                                Truth.assertThat(maxCurrentLineSpan).isEqualTo(4)
+                                TvGridItemSpan(1)
+                            }
+                            5 -> {
+                                Truth.assertThat(maxLineSpan).isEqualTo(4)
+                                Truth.assertThat(maxCurrentLineSpan).isEqualTo(3)
+                                TvGridItemSpan(1)
+                            }
+                            else -> error("Out of index span queried")
+                        }
+                    },
+                ) {
+                    Box(Modifier.height(itemHeight).testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(columnWidth * 3)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemHeight)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemHeight)
+            .assertLeftPositionInRootIsEqualTo(columnWidth)
+        rule.onNodeWithTag("4")
+            .assertTopPositionInRootIsEqualTo(itemHeight * 2)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("5")
+            .assertTopPositionInRootIsEqualTo(itemHeight * 2)
+            .assertLeftPositionInRootIsEqualTo(columnWidth)
+    }
+
+    @Test
+    fun spansWithHorizontalSpacing() {
+        val columns = 4
+        val columnWidth = with(rule.density) { 5.toDp() }
+        val itemHeight = with(rule.density) { 10.toDp() }
+        val spacing = with(rule.density) { 4.toDp() }
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(columns),
+                modifier = Modifier.requiredSize(
+                    columnWidth * columns + spacing * (columns - 1),
+                    itemHeight
+                ),
+                horizontalArrangement = Arrangement.spacedBy(spacing)
+            ) {
+                items(
+                    count = 2,
+                    span = { index ->
+                        when (index) {
+                            0 -> TvGridItemSpan(1)
+                            1 -> TvGridItemSpan(3)
+                            else -> error("Out of index span queried")
+                        }
+                    }
+                ) {
+                    Box(Modifier.height(itemHeight).testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(columnWidth)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(columnWidth + spacing)
+            .assertWidthIsEqualTo(columnWidth * 3 + spacing * 2)
+    }
+
+    @Test
+    fun spansMultipleBlocks() {
+        val columns = 4
+        val columnWidth = with(rule.density) { 5.toDp() }
+        val itemHeight = with(rule.density) { 10.toDp() }
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(columns),
+                modifier = Modifier.requiredSize(columnWidth * columns, itemHeight)
+            ) {
+                items(
+                    count = 1,
+                    span = { index ->
+                        when (index) {
+                            0 -> TvGridItemSpan(1)
+                            else -> error("Out of index span queried")
+                        }
+                    }
+                ) {
+                    Box(Modifier.height(itemHeight).testTag("0"))
+                }
+                item(span = {
+                    if (maxCurrentLineSpan != 3) error("Wrong maxSpan")
+                    TvGridItemSpan(2)
+                }) {
+                    Box(Modifier.height(itemHeight).testTag("1"))
+                }
+                items(
+                    count = 1,
+                    span = { index ->
+                        if (maxCurrentLineSpan != 1 || index != 0) {
+                            error("Wrong span calculation parameters")
+                        }
+                        TvGridItemSpan(1)
+                    }
+                ) {
+                    if (it != 0) error("Wrong index")
+                    Box(Modifier.height(itemHeight).testTag("2"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(columnWidth)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(columnWidth)
+            .assertWidthIsEqualTo(columnWidth * 2)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(columnWidth * 3)
+            .assertWidthIsEqualTo(columnWidth)
+    }
+
+    @Test
+    fun spansLineBreak() {
+        val columns = 4
+        val columnWidth = with(rule.density) { 5.toDp() }
+        val itemHeight = with(rule.density) { 10.toDp() }
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(columns),
+                modifier = Modifier.requiredSize(columnWidth * columns, itemHeight * 3)
+            ) {
+                item(span = {
+                    if (maxCurrentLineSpan != 4) error("Wrong maxSpan")
+                    TvGridItemSpan(3)
+                }) {
+                    Box(Modifier.height(itemHeight).testTag("0"))
+                }
+                items(
+                    count = 4,
+                    span = { index ->
+                        if (maxCurrentLineSpan != when (index) {
+                                0 -> 1
+                                1 -> 2
+                                2 -> 1
+                                3 -> 2
+                                else -> error("Wrong index")
+                            }
+                        ) error("Wrong maxSpan")
+                        TvGridItemSpan(listOf(2, 1, 2, 2)[index])
+                    }
+                ) {
+                    Box(Modifier.height(itemHeight).testTag((it + 1).toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(columnWidth * 3)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemHeight)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(columnWidth * 2)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemHeight)
+            .assertLeftPositionInRootIsEqualTo(columnWidth * 2)
+            .assertWidthIsEqualTo(columnWidth)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemHeight * 2)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(columnWidth * 2)
+        rule.onNodeWithTag("4")
+            .assertTopPositionInRootIsEqualTo(itemHeight * 2)
+            .assertLeftPositionInRootIsEqualTo(columnWidth * 2)
+            .assertWidthIsEqualTo(columnWidth * 2)
+    }
+
+    @Test
+    fun spansCalculationDoesntCrash() {
+        // regression from b/222530458
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(2),
+                state = state,
+                modifier = Modifier.size(100.dp)
+            ) {
+                repeat(100) {
+                    item(span = { TvGridItemSpan(maxLineSpan) }) {
+                        Box(Modifier.fillMaxWidth().height(1.dp))
+                    }
+                    items(10) {
+                        Box(Modifier.fillMaxWidth().height(1.dp))
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(state.layoutInfo.totalItemsCount)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridTest.kt
new file mode 100644
index 0000000..4f170ec
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridTest.kt
@@ -0,0 +1,1070 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import android.os.Build
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertPixels
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsMatcher.Companion.keyIsDefined
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.tv.foundation.lazy.list.setContentWithTestViewConfiguration
+import com.google.common.collect.Range
+import com.google.common.truth.IntegerSubject
+import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+class LazyGridTest(
+    private val orientation: Orientation
+) : BaseLazyGridTestWithOrientation(orientation) {
+    private val LazyGridTag = "LazyGridTag"
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun initParameters(): Array<Any> = arrayOf(
+            Orientation.Vertical,
+            Orientation.Horizontal,
+        )
+    }
+
+    @Test
+    fun lazyGridShowsOneItem() {
+        val itemTestTag = "itemTestTag"
+
+        rule.setContent {
+            LazyGrid(
+                cells = 3
+            ) {
+                item {
+                    Spacer(
+                        Modifier.size(10.dp).testTag(itemTestTag)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(itemTestTag)
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun lazyGridShowsOneLine() {
+        val items = (1..5).map { it.toString() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 3,
+                modifier = Modifier.axisSize(300.dp, 100.dp)
+            ) {
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(101.dp).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("5")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun lazyGridShowsSecondLineOnScroll() {
+        val items = (1..12).map { it.toString() }
+
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 3,
+                modifier = Modifier.mainAxisSize(200.dp).testTag(LazyGridTag)
+            ) {
+                items(items) {
+                    Box(Modifier.mainAxisSize(101.dp).testTag(it).focusable())
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("5")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("6")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("10")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("11")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("12")
+            .assertIsNotDisplayed()
+    }
+
+    @Test
+    fun lazyGridScrollHidesFirstLine() {
+        val items = (1..9).map { it.toString() }
+
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 3,
+                modifier = Modifier.mainAxisSize(200.dp).testTag(LazyGridTag),
+            ) {
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(101.dp).testTag(it).focusable())
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag("1")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("5")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("6")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("7")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("8")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("9")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun adaptiveLazyGridFillsAllCrossAxisSize() {
+        val items = (1..5).map { it.toString() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = TvGridCells.Adaptive(130.dp),
+                modifier = Modifier.axisSize(300.dp, 100.dp)
+            ) {
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(101.dp).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertCrossAxisStartPositionInRootIsEqualTo(150.dp)
+
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("5")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun adaptiveLazyGridAtLeastOneSlot() {
+        val items = (1..3).map { it.toString() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = TvGridCells.Adaptive(301.dp),
+                modifier = Modifier.axisSize(300.dp, 100.dp)
+            ) {
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(101.dp).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun adaptiveLazyGridAppliesHorizontalSpacings() {
+        val items = (1..3).map { it.toString() }
+
+        val spacing = with(rule.density) { 10.toDp() }
+        val itemSize = with(rule.density) { 100.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = TvGridCells.Adaptive(itemSize),
+                modifier = Modifier.axisSize(itemSize * 3 + spacing * 2, itemSize),
+                crossAxisSpacedBy = spacing
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize + spacing)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize * 2 + spacing * 2)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun adaptiveLazyGridAppliesHorizontalSpacingsWithContentPaddings() {
+        val items = (1..3).map { it.toString() }
+
+        val spacing = with(rule.density) { 8.toDp() }
+        val itemSize = with(rule.density) { 40.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = TvGridCells.Adaptive(itemSize),
+                modifier = Modifier.axisSize(itemSize * 3 + spacing * 4, itemSize),
+                crossAxisSpacedBy = spacing,
+                contentPadding = PaddingValues(crossAxis = spacing)
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize + spacing * 2)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize * 2 + spacing * 3)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun adaptiveLazyGridAppliesVerticalSpacings() {
+        val items = (1..3).map { it.toString() }
+
+        val spacing = with(rule.density) { 4.toDp() }
+        val itemSize = with(rule.density) { 32.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = TvGridCells.Adaptive(itemSize),
+                modifier = Modifier.axisSize(itemSize, itemSize * 3 + spacing * 2),
+                mainAxisSpacedBy = spacing
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize + spacing)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize * 2 + spacing * 2)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun adaptiveLazyGridAppliesVerticalSpacingsWithContentPadding() {
+        val items = (1..3).map { it.toString() }
+
+        val spacing = with(rule.density) { 16.toDp() }
+        val itemSize = with(rule.density) { 72.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = TvGridCells.Adaptive(itemSize),
+                modifier = Modifier.axisSize(itemSize, itemSize * 3 + spacing * 2),
+                mainAxisSpacedBy = spacing,
+                contentPadding = PaddingValues(mainAxis = spacing)
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing * 2 + itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing * 3 + itemSize * 2)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun fixedLazyGridAppliesVerticalSpacings() {
+        val items = (1..4).map { it.toString() }
+
+        val spacing = with(rule.density) { 24.toDp() }
+        val itemSize = with(rule.density) { 80.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.axisSize(itemSize, itemSize * 2 + spacing),
+                mainAxisSpacedBy = spacing,
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing + itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing + itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun fixedLazyGridAppliesHorizontalSpacings() {
+        val items = (1..4).map { it.toString() }
+
+        val spacing = with(rule.density) { 15.toDp() }
+        val itemSize = with(rule.density) { 30.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.axisSize(itemSize * 2 + spacing, itemSize * 2),
+                crossAxisSpacedBy = spacing
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing + itemSize)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing + itemSize)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun fixedLazyGridAppliesVerticalSpacingsWithContentPadding() {
+        val items = (1..4).map { it.toString() }
+
+        val spacing = with(rule.density) { 30.toDp() }
+        val itemSize = with(rule.density) { 77.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.axisSize(itemSize, itemSize * 2 + spacing),
+                mainAxisSpacedBy = spacing,
+                contentPadding = PaddingValues(mainAxis = spacing)
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing * 2 + itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+            .assertMainAxisStartPositionInRootIsEqualTo(spacing * 2 + itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun fixedLazyGridAppliesHorizontalSpacingsWithContentPadding() {
+        val items = (1..4).map { it.toString() }
+
+        val spacing = with(rule.density) { 22.toDp() }
+        val itemSize = with(rule.density) { 44.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.axisSize(itemSize * 2 + spacing * 3, itemSize * 2),
+                crossAxisSpacedBy = spacing,
+                contentPadding = PaddingValues(crossAxis = spacing)
+            ) {
+                items(items) {
+                    Spacer(Modifier.size(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing * 2 + itemSize)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+            .assertCrossAxisStartPositionInRootIsEqualTo(spacing * 2 + itemSize)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun usedWithArray() {
+        val items = arrayOf("1", "2", "3", "4")
+
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.crossAxisSize(itemSize * 2)
+            ) {
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("4")
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize)
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun usedWithArrayIndexed() {
+        val items = arrayOf("1", "2", "3", "4")
+
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                Modifier.crossAxisSize(itemSize * 2)
+            ) {
+                itemsIndexed(items) { index, item ->
+                    Spacer(Modifier.mainAxisSize(itemSize).testTag("$index*$item"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0*1")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1*2")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2*3")
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("3*4")
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize)
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun changeItemsCountAndScrollImmediately() {
+        lateinit var state: TvLazyGridState
+        var count by mutableStateOf(100)
+        val composedIndexes = mutableListOf<Int>()
+        rule.setContent {
+            state = rememberLazyGridState()
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.mainAxisSize(10.dp),
+                state = state
+            ) {
+                items(count) { index ->
+                    composedIndexes.add(index)
+                    Box(Modifier.size(20.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            composedIndexes.clear()
+            count = 10
+            runBlocking(AutoTestFrameClock()) {
+                // we try to scroll to the index after 10, but we expect that the component will
+                // already be aware there is a new count and not compose items with index > 10
+                state.scrollToItem(50)
+            }
+            composedIndexes.forEach {
+                Truth.assertThat(it).isLessThan(count)
+            }
+            Truth.assertThat(state.firstVisibleItemIndex).isEqualTo(9)
+        }
+    }
+
+    @Test
+    fun maxIntElements() {
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.size(itemSize * 2).testTag(LazyGridTag),
+                state = TvLazyGridState(firstVisibleItemIndex = Int.MAX_VALUE - 3)
+            ) {
+                items(Int.MAX_VALUE) {
+                    Box(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("${Int.MAX_VALUE - 3}")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("${Int.MAX_VALUE - 2}")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("${Int.MAX_VALUE - 1}")
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("${Int.MAX_VALUE}").assertDoesNotExist()
+        rule.onNodeWithTag("0").assertDoesNotExist()
+    }
+
+    @Test
+    fun pointerInputScrollingIsAllowedWhenUserScrollingIsEnabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.size(itemSize * 3).testTag(LazyGridTag),
+                userScrollEnabled = true
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag("1")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun pointerInputScrollingIsDisallowedWhenUserScrollingIsDisabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.size(itemSize * 3).testTag(LazyGridTag),
+                userScrollEnabled = false
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(2)
+
+        rule.onNodeWithTag("1")
+            .assertMainAxisStartPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun programmaticScrollingIsAllowedWhenUserScrollingIsDisabled() {
+        val itemSizePx = 30f
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.size(itemSize * 3),
+                state = rememberLazyGridState().also { state = it },
+                userScrollEnabled = false,
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(itemSizePx)
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertMainAxisStartPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun semanticScrollingIsDisallowedWhenUserScrollingIsDisabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.size(itemSize * 3).testTag(LazyGridTag),
+                userScrollEnabled = false,
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyGridTag)
+            .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.ScrollBy))
+            .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.ScrollToIndex))
+            // but we still have a read only scroll range property
+            .assert(
+                keyIsDefined(
+                    if (orientation == Orientation.Vertical) {
+                        SemanticsProperties.VerticalScrollAxisRange
+                    } else {
+                        SemanticsProperties.HorizontalScrollAxisRange
+                    }
+                )
+            )
+    }
+
+    @Test
+    fun rtl() {
+        val gridCrossAxisSize = 30
+        val gridCrossAxisSizeDp = with(rule.density) { gridCrossAxisSize.toDp() }
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                LazyGrid(
+                    cells = 3,
+                    modifier = Modifier.crossAxisSize(gridCrossAxisSizeDp)
+                ) {
+                    items(3) {
+                        Box(Modifier.mainAxisSize(1.dp).testTag("$it"))
+                    }
+                }
+            }
+        }
+
+        val tags = if (orientation == Orientation.Vertical) {
+            listOf("0", "1", "2")
+        } else {
+            listOf("2", "1", "0")
+        }
+        rule.onNodeWithTag(tags[0])
+            .assertCrossAxisStartPositionInRootIsEqualTo(gridCrossAxisSizeDp * 2 / 3)
+        rule.onNodeWithTag(tags[1])
+            .assertCrossAxisStartPositionInRootIsEqualTo(gridCrossAxisSizeDp / 3)
+        rule.onNodeWithTag(tags[2]).assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun withMissingItems() {
+        val itemMainAxisSize = with(rule.density) { 30.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.mainAxisSize(itemMainAxisSize + 1.dp),
+                state = state
+            ) {
+                items((0..8).map { it.toString() }) {
+                    if (it != "3") {
+                        Box(Modifier.mainAxisSize(itemMainAxisSize).testTag(it))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0").assertIsDisplayed()
+        rule.onNodeWithTag("1").assertIsDisplayed()
+        rule.onNodeWithTag("2").assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(3)
+            }
+        }
+
+        rule.onNodeWithTag("0").assertIsNotDisplayed()
+        rule.onNodeWithTag("1").assertIsNotDisplayed()
+        rule.onNodeWithTag("2").assertIsDisplayed()
+        rule.onNodeWithTag("4").assertIsDisplayed()
+        rule.onNodeWithTag("5").assertIsDisplayed()
+        rule.onNodeWithTag("6").assertDoesNotExist()
+        rule.onNodeWithTag("7").assertDoesNotExist()
+    }
+
+    @Test
+    fun passingNegativeItemsCountIsNotAllowed() {
+        var exception: Exception? = null
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(cells = 1) {
+                try {
+                    items(-1) {
+                        Box(Modifier)
+                    }
+                } catch (e: Exception) {
+                    exception = e
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            Truth.assertThat(exception).isInstanceOf(IllegalArgumentException::class.java)
+        }
+    }
+
+    @Test
+    fun recomposingWithNewComposedModifierObjectIsNotCausingRemeasure() {
+        var remeasureCount = 0
+        val layoutModifier = Modifier.layout { measurable, constraints ->
+            remeasureCount++
+            val placeable = measurable.measure(constraints)
+            layout(placeable.width, placeable.height) {
+                placeable.place(0, 0)
+            }
+        }
+        val counter = mutableStateOf(0)
+
+        rule.setContentWithTestViewConfiguration {
+            counter.value // just to trigger recomposition
+            LazyGrid(
+                cells = 1,
+                // this will return a new object everytime causing LazyGrid recomposition
+                // without causing remeasure
+                modifier = Modifier.composed { layoutModifier }
+            ) {
+                items(1) {
+                    Spacer(Modifier.size(10.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            Truth.assertThat(remeasureCount).isEqualTo(1)
+            counter.value++
+        }
+
+        rule.runOnIdle {
+            Truth.assertThat(remeasureCount).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun scrollingALotDoesntCauseLazyLayoutRecomposition() {
+        var recomposeCount = 0
+        lateinit var state: TvLazyGridState
+
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyGridState()
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.composed {
+                    recomposeCount++
+                    Modifier
+                }.size(100.dp),
+                state
+            ) {
+                items(1000) {
+                    Spacer(Modifier.size(100.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            Truth.assertThat(recomposeCount).isEqualTo(1)
+
+            runBlocking {
+                state.scrollToItem(100)
+            }
+        }
+
+        rule.runOnIdle {
+            Truth.assertThat(recomposeCount).isEqualTo(1)
+        }
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    fun zIndexOnItemAffectsDrawingOrder() {
+        rule.setContentWithTestViewConfiguration {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.size(6.dp).testTag(LazyGridTag)
+            ) {
+                items(listOf(Color.Blue, Color.Green, Color.Red)) { color ->
+                    Box(
+                        Modifier
+                            .axisSize(6.dp, 2.dp)
+                            .zIndex(if (color == Color.Green) 1f else 0f)
+                            .drawBehind {
+                                drawRect(
+                                    color,
+                                    topLeft = Offset(-10.dp.toPx(), -10.dp.toPx()),
+                                    size = Size(20.dp.toPx(), 20.dp.toPx())
+                                )
+                            })
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyGridTag)
+            .captureToImage()
+            .assertPixels { Color.Green }
+    }
+
+    @Test
+    fun customGridCells() {
+        val items = (1..5).map { it.toString() }
+
+        rule.setContent {
+            LazyGrid(
+                // Two columns in ratio 1:2
+                cells = object : TvGridCells {
+                    override fun Density.calculateCrossAxisCellSizes(
+                        availableSize: Int,
+                        spacing: Int
+                    ): List<Int> {
+                        val availableCrossAxis = availableSize - spacing
+                        val columnSize = availableCrossAxis / 3
+                        return listOf(columnSize, columnSize * 2)
+                    }
+                },
+                modifier = Modifier.axisSize(300.dp, 100.dp)
+            ) {
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(101.dp).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(100.dp)
+
+        rule.onNodeWithTag("2")
+            .assertCrossAxisStartPositionInRootIsEqualTo(100.dp)
+            .assertCrossAxisSizeIsEqualTo(200.dp)
+
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("5")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun onlyOneInitialMeasurePass() {
+        val items by mutableStateOf((1..20).toList())
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            LazyGrid(
+                1,
+                Modifier.requiredSize(100.dp).testTag(LazyGridTag),
+                state = state
+            ) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.numMeasurePasses).isEqualTo(1)
+        }
+    }
+}
+
+internal fun IntegerSubject.isEqualTo(expected: Int, tolerance: Int) {
+    isIn(Range.closed(expected - tolerance, expected + tolerance))
+}
+
+internal fun ComposeContentTestRule.keyPress(keyCode: Int, numberOfPresses: Int = 1) {
+    for (index in 0 until numberOfPresses)
+        InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(keyCode)
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsContentPaddingTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsContentPaddingTest.kt
new file mode 100644
index 0000000..0f06a2b
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsContentPaddingTest.kt
@@ -0,0 +1,1206 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.animation.core.snap
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.tv.foundation.lazy.list.setContentWithTestViewConfiguration
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class LazyGridsContentPaddingTest {
+    private val LazyListTag = "LazyList"
+    private val ItemTag = "item"
+    private val ContainerTag = "container"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var itemSize: Dp = Dp.Infinity
+    private var smallPaddingSize: Dp = Dp.Infinity
+    private var itemSizePx = 50f
+    private var smallPaddingSizePx = 12f
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSize = itemSizePx.toDp()
+            smallPaddingSize = smallPaddingSizePx.toDp()
+        }
+    }
+
+    @Test
+    fun verticalGrid_contentPaddingIsApplied() {
+        lateinit var state: TvLazyGridState
+        val containerSize = itemSize * 2
+        val largePaddingSize = itemSize
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(containerSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyGridState().also { state = it },
+                contentPadding = PaddingValues(
+                    start = smallPaddingSize,
+                    top = largePaddingSize,
+                    end = smallPaddingSize,
+                    bottom = largePaddingSize
+                )
+            ) {
+                items(listOf(1)) {
+                    Spacer(Modifier.height(itemSize).testTag(ItemTag))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ItemTag)
+            .assertLeftPositionInRootIsEqualTo(smallPaddingSize)
+            .assertTopPositionInRootIsEqualTo(largePaddingSize)
+            .assertWidthIsEqualTo(containerSize - smallPaddingSize * 2)
+            .assertHeightIsEqualTo(itemSize)
+
+        state.scrollBy(largePaddingSize)
+
+        rule.onNodeWithTag(ItemTag)
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertHeightIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun verticalGrid_contentPaddingIsNotAffectingScrollPosition() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(itemSize * 2)
+                    .testTag(LazyListTag),
+                state = rememberLazyGridState().also { state = it },
+                contentPadding = PaddingValues(
+                    top = itemSize,
+                    bottom = itemSize
+                )
+            ) {
+                items(listOf(1)) {
+                    Spacer(Modifier.height(itemSize).testTag(ItemTag))
+                }
+            }
+        }
+
+        state.assertScrollPosition(0, 0.dp)
+
+        state.scrollBy(itemSize)
+
+        state.assertScrollPosition(0, itemSize)
+    }
+
+    @Test
+    fun verticalGrid_scrollForwardItemWithinStartPaddingDisplayed() {
+        lateinit var state: TvLazyGridState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(padding * 2 + itemSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyGridState().also { state = it },
+                contentPadding = PaddingValues(
+                    top = padding,
+                    bottom = padding
+                )
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(padding)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize + padding)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2 + padding)
+
+        state.scrollBy(padding)
+
+        state.assertScrollPosition(1, padding - itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize * 3)
+    }
+
+    @Test
+    fun verticalGrid_scrollBackwardItemWithinStartPaddingDisplayed() {
+        lateinit var state: TvLazyGridState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(itemSize + padding * 2)
+                    .testTag(LazyListTag),
+                state = rememberLazyGridState().also { state = it },
+                contentPadding = PaddingValues(
+                    top = padding,
+                    bottom = padding
+                )
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+        state.scrollBy(-itemSize * 1.5f)
+
+        state.assertScrollPosition(1, itemSize * 0.5f)
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize * 1.5f - padding)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2.5f - padding)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 3.5f - padding)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize * 4.5f - padding)
+    }
+
+    @Test
+    fun verticalGrid_scrollForwardTillTheEnd() {
+        lateinit var state: TvLazyGridState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(padding * 2 + itemSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyGridState().also { state = it },
+                contentPadding = PaddingValues(
+                    top = padding,
+                    bottom = padding
+                )
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+
+        state.assertScrollPosition(3, 0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize - padding)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2 - padding)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize * 3 - padding)
+
+        // there are no space to scroll anymore, so it should change nothing
+        state.scrollBy(10.dp)
+
+        state.assertScrollPosition(3, 0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize - padding)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2 - padding)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize * 3 - padding)
+    }
+
+    @Test
+    fun verticalGrid_scrollForwardTillTheEndAndABitBack() {
+        lateinit var state: TvLazyGridState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            TvLazyVerticalGrid(
+                columns = TvGridCells.Fixed(1),
+                modifier = Modifier.requiredSize(padding * 2 + itemSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyGridState().also { state = it },
+                contentPadding = PaddingValues(
+                    top = padding,
+                    bottom = padding
+                )
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+        state.scrollBy(-itemSize / 2)
+
+        state.assertScrollPosition(2, itemSize / 2)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize * 1.5f - padding)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2.5f - padding)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize * 3.5f - padding)
+    }
+
+    @Test
+    fun verticalGrid_contentPaddingFixedWidthContainer() {
+        rule.setContent {
+            Box(modifier = Modifier.testTag(ContainerTag).width(itemSize + 8.dp)) {
+                TvLazyVerticalGrid(
+                    columns = TvGridCells.Fixed(1),
+                    contentPadding = PaddingValues(
+                        start = 2.dp,
+                        top = 4.dp,
+                        end = 6.dp,
+                        bottom = 8.dp
+                    )
+                ) {
+                    items(listOf(1)) {
+                        Spacer(Modifier.size(itemSize).testTag(ItemTag))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ItemTag)
+            .assertLeftPositionInRootIsEqualTo(2.dp)
+            .assertTopPositionInRootIsEqualTo(4.dp)
+            .assertWidthIsEqualTo(itemSize)
+            .assertHeightIsEqualTo(itemSize)
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(itemSize + 2.dp + 6.dp)
+            .assertHeightIsEqualTo(itemSize + 4.dp + 8.dp)
+    }
+
+    @Test
+    fun verticalGrid_contentPaddingAndNoContent() {
+        rule.setContent {
+            Box(modifier = Modifier.testTag(ContainerTag)) {
+                TvLazyVerticalGrid(
+                    columns = TvGridCells.Fixed(1),
+                    contentPadding = PaddingValues(
+                        start = 2.dp,
+                        top = 4.dp,
+                        end = 6.dp,
+                        bottom = 8.dp
+                    )
+                ) { }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(8.dp)
+            .assertHeightIsEqualTo(12.dp)
+    }
+
+    @Test
+    fun verticalGrid_contentPaddingAndZeroSizedItem() {
+        rule.setContent {
+            Box(modifier = Modifier.testTag(ContainerTag)) {
+                TvLazyVerticalGrid(
+                    columns = TvGridCells.Fixed(1),
+                    contentPadding = PaddingValues(
+                        start = 2.dp,
+                        top = 4.dp,
+                        end = 6.dp,
+                        bottom = 8.dp
+                    )
+                ) {
+                    items(0) { }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertWidthIsEqualTo(8.dp)
+            .assertHeightIsEqualTo(12.dp)
+    }
+
+    @Test
+    fun verticalGrid_contentPaddingAndReverseLayout() {
+        val topPadding = itemSize * 2
+        val bottomPadding = itemSize / 2
+        val listSize = itemSize * 3
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                reverseLayout = true,
+                state = rememberLazyGridState().also { state = it },
+                modifier = Modifier.size(listSize),
+                contentPadding = PaddingValues(top = topPadding, bottom = bottomPadding),
+            ) {
+                items(3) { index ->
+                    Box(Modifier.size(itemSize).testTag("$index"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(listSize - bottomPadding - itemSize)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(listSize - bottomPadding - itemSize * 2)
+        // Partially visible.
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(-itemSize / 2)
+
+        // Scroll to the top.
+        state.scrollBy(itemSize * 2.5f)
+
+        rule.onNodeWithTag("2").assertTopPositionInRootIsEqualTo(topPadding)
+        // Shouldn't be visible
+        rule.onNodeWithTag("1").assertIsNotDisplayed()
+        rule.onNodeWithTag("0").assertIsNotDisplayed()
+    }
+
+    @Test
+    fun column_overscrollWithContentPadding() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize + smallPaddingSize * 2)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(
+                        vertical = smallPaddingSize
+                    )
+                ) {
+                    items(2) {
+                        Box(Modifier.testTag("$it").height(itemSize))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(smallPaddingSize)
+            .assertHeightIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(smallPaddingSize + itemSize)
+            .assertHeightIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            runBlocking {
+                // itemSizePx is the maximum offset, plus if we overscroll the content padding
+                // the layout mechanism will decide the item 0 is not needed until we start
+                // filling the over scrolled gap.
+                state.scrollBy(value = itemSizePx + smallPaddingSizePx * 1.5f)
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(smallPaddingSize)
+            .assertHeightIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(smallPaddingSize - itemSize)
+            .assertHeightIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_initialState() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(0, 0.dp)
+            state.assertVisibleItems(0 to 0.dp)
+            state.assertLayoutInfoOffsetRange(-itemSize, itemSize * 0.5f)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollByPadding() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(1, 0.dp)
+            state.assertVisibleItems(0 to -itemSize, 1 to 0.dp)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollToLastItem() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollTo(3)
+
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollToLastItemByDelta() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+
+        rule.onNodeWithTag("1")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollTillTheEnd() {
+        // the whole end content padding is displayed
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 4.5f)
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(-itemSize * 0.5f)
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, itemSize * 1.5f)
+            state.assertVisibleItems(3 to -itemSize * 1.5f)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_initialState() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(0, 0.dp)
+            state.assertVisibleItems(0 to 0.dp)
+            state.assertLayoutInfoOffsetRange(-itemSize * 2, -itemSize * 0.5f)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollByPadding() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 2)
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(2, 0.dp)
+            state.assertVisibleItems(0 to -itemSize * 2, 1 to -itemSize, 2 to 0.dp)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollToLastItem() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollTo(3)
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(1 to -itemSize * 2, 2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollToLastItemByDelta() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(1 to -itemSize * 2, 2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollTillTheEnd() {
+        // only the end content padding is displayed
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            state = rememberLazyGridState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    state = state,
+                    contentPadding = PaddingValues(vertical = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(
+            itemSize * 1.5f + // container size
+                itemSize * 2 + // start padding
+                itemSize * 3 // all items
+        )
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, itemSize * 3.5f)
+            state.assertVisibleItems(3 to -itemSize * 3.5f)
+        }
+    }
+
+    // @Test
+    // fun row_contentPaddingIsApplied() {
+    //     lateinit var state: LazyGridState
+    //     val containerSize = itemSize * 2
+    //     val largePaddingSize = itemSize
+    //     rule.setContent {
+    //         LazyRow(
+    //             modifier = Modifier.requiredSize(containerSize)
+    //                 .testTag(LazyListTag),
+    //             state = rememberLazyGridState().also { state = it },
+    //             contentPadding = PaddingValues(
+    //                 top = smallPaddingSize,
+    //                 start = largePaddingSize,
+    //                 bottom = smallPaddingSize,
+    //                 end = largePaddingSize
+    //             )
+    //         ) {
+    //             items(listOf(1)) {
+    //                 Spacer(Modifier.fillParentMaxHeight().width(itemSize).testTag(ItemTag))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(ItemTag)
+    //         .assertTopPositionInRootIsEqualTo(smallPaddingSize)
+    //         .assertLeftPositionInRootIsEqualTo(largePaddingSize)
+    //         .assertHeightIsEqualTo(containerSize - smallPaddingSize * 2)
+    //         .assertWidthIsEqualTo(itemSize)
+
+    //     state.scrollBy(largePaddingSize)
+
+    //     rule.onNodeWithTag(ItemTag)
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //         .assertWidthIsEqualTo(itemSize)
+    // }
+
+    // @Test
+    // fun row_contentPaddingIsNotAffectingScrollPosition() {
+    //     lateinit var state: LazyGridState
+    //     val itemSize = with(rule.density) {
+    //         50.dp.roundToPx().toDp()
+    //     }
+    //     rule.setContent {
+    //         LazyRow(
+    //             modifier = Modifier.requiredSize(itemSize * 2)
+    //                 .testTag(LazyListTag),
+    //             state = rememberLazyGridState().also { state = it },
+    //             contentPadding = PaddingValues(
+    //                 start = itemSize,
+    //                 end = itemSize
+    //             )
+    //         ) {
+    //             items(listOf(1)) {
+    //                 Spacer(Modifier.fillParentMaxHeight().width(itemSize).testTag(ItemTag))
+    //             }
+    //         }
+    //     }
+
+    //     state.assertScrollPosition(0, 0.dp)
+
+    //     state.scrollBy(itemSize)
+
+    //     state.assertScrollPosition(0, itemSize)
+    // }
+
+    // @Test
+    // fun row_scrollForwardItemWithinStartPaddingDisplayed() {
+    //     lateinit var state: LazyGridState
+    //     val padding = itemSize * 1.5f
+    //     rule.setContent {
+    //         LazyRow(
+    //             modifier = Modifier.requiredSize(padding * 2 + itemSize)
+    //                 .testTag(LazyListTag),
+    //             state = rememberLazyGridState().also { state = it },
+    //             contentPadding = PaddingValues(
+    //                 start = padding,
+    //                 end = padding
+    //             )
+    //         ) {
+    //             items((0..3).toList()) {
+    //                 Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(padding)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize + padding)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2 + padding)
+
+    //     state.scrollBy(padding)
+
+    //     state.assertScrollPosition(1, padding - itemSize)
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2)
+    //     rule.onNodeWithTag("3")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 3)
+    // }
+
+    // @Test
+    // fun row_scrollBackwardItemWithinStartPaddingDisplayed() {
+    //     lateinit var state: LazyGridState
+    //     val padding = itemSize * 1.5f
+    //     rule.setContent {
+    //         LazyRow(
+    //             modifier = Modifier.requiredSize(itemSize + padding * 2)
+    //                 .testTag(LazyListTag),
+    //             state = rememberLazyGridState().also { state = it },
+    //             contentPadding = PaddingValues(
+    //                 start = padding,
+    //                 end = padding
+    //             )
+    //         ) {
+    //             items((0..3).toList()) {
+    //                 Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+    //             }
+    //         }
+    //     }
+
+    //     state.scrollBy(itemSize * 3)
+    //     state.scrollBy(-itemSize * 1.5f)
+
+    //     state.assertScrollPosition(1, itemSize * 0.5f)
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 1.5f - padding)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2.5f - padding)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 3.5f - padding)
+    //     rule.onNodeWithTag("3")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 4.5f - padding)
+    // }
+
+    // @Test
+    // fun row_scrollForwardTillTheEnd() {
+    //     lateinit var state: LazyGridState
+    //     val padding = itemSize * 1.5f
+    //     rule.setContent {
+    //         LazyRow(
+    //             modifier = Modifier.requiredSize(padding * 2 + itemSize)
+    //                 .testTag(LazyListTag),
+    //             state = rememberLazyGridState().also { state = it },
+    //             contentPadding = PaddingValues(
+    //                 start = padding,
+    //                 end = padding
+    //             )
+    //         ) {
+    //             items((0..3).toList()) {
+    //                 Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+    //             }
+    //         }
+    //     }
+
+    //     state.scrollBy(itemSize * 3)
+
+    //     state.assertScrollPosition(3, 0.dp)
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize - padding)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2 - padding)
+    //     rule.onNodeWithTag("3")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 3 - padding)
+
+    //     // there are no space to scroll anymore, so it should change nothing
+    //     state.scrollBy(10.dp)
+
+    //     state.assertScrollPosition(3, 0.dp)
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize - padding)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2 - padding)
+    //     rule.onNodeWithTag("3")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 3 - padding)
+    // }
+
+    // @Test
+    // fun row_scrollForwardTillTheEndAndABitBack() {
+    //     lateinit var state: LazyGridState
+    //     val padding = itemSize * 1.5f
+    //     rule.setContent {
+    //         LazyRow(
+    //             modifier = Modifier.requiredSize(padding * 2 + itemSize)
+    //                 .testTag(LazyListTag),
+    //             state = rememberLazyGridState().also { state = it },
+    //             contentPadding = PaddingValues(
+    //                 start = padding,
+    //                 end = padding
+    //             )
+    //         ) {
+    //             items((0..3).toList()) {
+    //                 Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+    //             }
+    //         }
+    //     }
+
+    //     state.scrollBy(itemSize * 3)
+    //     state.scrollBy(-itemSize / 2)
+
+    //     state.assertScrollPosition(2, itemSize / 2)
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 1.5f - padding)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2.5f - padding)
+    //     rule.onNodeWithTag("3")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 3.5f - padding)
+    // }
+
+    // @Test
+    // fun row_contentPaddingAndWrapContent() {
+    //     rule.setContent {
+    //         Box(modifier = Modifier.testTag(ContainerTag)) {
+    //             LazyRow(
+    //                 contentPadding = PaddingValues(
+    //                     start = 2.dp,
+    //                     top = 4.dp,
+    //                     end = 6.dp,
+    //                     bottom = 8.dp
+    //                 )
+    //             ) {
+    //                 items(listOf(1)) {
+    //                     Spacer(Modifier.requiredSize(itemSize).testTag(ItemTag))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(ItemTag)
+    //         .assertLeftPositionInRootIsEqualTo(2.dp)
+    //         .assertTopPositionInRootIsEqualTo(4.dp)
+    //         .assertWidthIsEqualTo(itemSize)
+    //         .assertHeightIsEqualTo(itemSize)
+
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //         .assertTopPositionInRootIsEqualTo(0.dp)
+    //         .assertWidthIsEqualTo(itemSize + 2.dp + 6.dp)
+    //         .assertHeightIsEqualTo(itemSize + 4.dp + 8.dp)
+    // }
+
+    // @Test
+    // fun row_contentPaddingAndNoContent() {
+    //     rule.setContent {
+    //         Box(modifier = Modifier.testTag(ContainerTag)) {
+    //             LazyRow(
+    //                 contentPadding = PaddingValues(
+    //                     start = 2.dp,
+    //                     top = 4.dp,
+    //                     end = 6.dp,
+    //                     bottom = 8.dp
+    //                 )
+    //             ) { }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //         .assertTopPositionInRootIsEqualTo(0.dp)
+    //         .assertWidthIsEqualTo(8.dp)
+    //         .assertHeightIsEqualTo(12.dp)
+    // }
+
+    // @Test
+    // fun row_contentPaddingAndZeroSizedItem() {
+    //     rule.setContent {
+    //         Box(modifier = Modifier.testTag(ContainerTag)) {
+    //             LazyRow(
+    //                 contentPadding = PaddingValues(
+    //                     start = 2.dp,
+    //                     top = 4.dp,
+    //                     end = 6.dp,
+    //                     bottom = 8.dp
+    //                 )
+    //             ) {
+    //                 items(0) {}
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //         .assertTopPositionInRootIsEqualTo(0.dp)
+    //         .assertWidthIsEqualTo(8.dp)
+    //         .assertHeightIsEqualTo(12.dp)
+    // }
+
+    // @Test
+    // fun row_contentPaddingAndReverseLayout() {
+    //     val startPadding = itemSize * 2
+    //     val endPadding = itemSize / 2
+    //     val listSize = itemSize * 3
+    //     lateinit var state: LazyGridState
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true,
+    //             state = rememberLazyGridState().also { state = it },
+    //             modifier = Modifier.requiredSize(listSize),
+    //             contentPadding = PaddingValues(start = startPadding, end = endPadding),
+    //         ) {
+    //             items(3) { index ->
+    //                 Box(Modifier.requiredSize(itemSize).testTag("$index"))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(listSize - endPadding - itemSize)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(listSize - endPadding - itemSize * 2)
+    //     // Partially visible.
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(-itemSize / 2)
+
+    //     // Scroll to the top.
+    //     state.scrollBy(itemSize * 2.5f)
+
+    //     rule.onNodeWithTag("2").assertLeftPositionInRootIsEqualTo(startPadding)
+    //     // Shouldn't be visible
+    //     rule.onNodeWithTag("1").assertIsNotDisplayed()
+    //     rule.onNodeWithTag("0").assertIsNotDisplayed()
+    // }
+
+    // @Test
+    // fun row_overscrollWithContentPadding() {
+    //     lateinit var state: LazyListState
+    //     rule.setContent {
+    //         state = rememberLazyListState()
+    //         Box(modifier = Modifier.testTag(ContainerTag).size(itemSize + smallPaddingSize * 2)) {
+    //             LazyRow(
+    //                 state = state,
+    //                 contentPadding = PaddingValues(
+    //                     horizontal = smallPaddingSize
+    //                 )
+    //             ) {
+    //                 items(2) {
+    //                     Box(Modifier.testTag("$it").fillParentMaxSize())
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(smallPaddingSize)
+    //         .assertWidthIsEqualTo(itemSize)
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(smallPaddingSize + itemSize)
+    //         .assertWidthIsEqualTo(itemSize)
+
+    //     rule.runOnIdle {
+    //         runBlocking {
+    //             // itemSizePx is the maximum offset, plus if we overscroll the content padding
+    //             // the layout mechanism will decide the item 0 is not needed until we start
+    //             // filling the over scrolled gap.
+    //             state.scrollBy(value = itemSizePx + smallPaddingSizePx * 1.5f)
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(smallPaddingSize)
+    //         .assertWidthIsEqualTo(itemSize)
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(smallPaddingSize - itemSize)
+    //         .assertWidthIsEqualTo(itemSize)
+    // }
+
+    private fun TvLazyGridState.scrollBy(offset: Dp) {
+        runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+            animateScrollBy(with(rule.density) { offset.roundToPx().toFloat() }, snap())
+        }
+    }
+
+    private fun TvLazyGridState.assertScrollPosition(index: Int, offset: Dp) = with(rule.density) {
+        assertThat(this@assertScrollPosition.firstVisibleItemIndex).isEqualTo(index)
+        assertThat(firstVisibleItemScrollOffset.toDp().value).isWithin(0.5f).of(offset.value)
+    }
+
+    private fun TvLazyGridState.assertLayoutInfoOffsetRange(from: Dp, to: Dp) = with(rule.density) {
+        assertThat(layoutInfo.viewportStartOffset to layoutInfo.viewportEndOffset)
+            .isEqualTo(from.roundToPx() to to.roundToPx())
+    }
+
+    private fun TvLazyGridState.assertVisibleItems(vararg expected: Pair<Int, Dp>) =
+        with(rule.density) {
+            assertThat(layoutInfo.visibleItemsInfo.map { it.index to it.offset.y })
+                .isEqualTo(expected.map { it.first to it.second.roundToPx() })
+        }
+
+    fun TvLazyGridState.scrollTo(index: Int) {
+        runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+            scrollToItem(index)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsIndexedTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsIndexedTest.kt
new file mode 100644
index 0000000..e99a386
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsIndexedTest.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import org.junit.Rule
+import org.junit.Test
+
+class LazyGridsIndexedTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun lazyVerticalGridShowsIndexedItems() {
+        val items = (1..4).map { it.toString() }
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(1), Modifier.height(200.dp)) {
+                itemsIndexed(items) { index, item ->
+                    Spacer(
+                        Modifier.height(101.dp).testTag("$index-$item")
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0-1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("1-2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2-3")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("3-4")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun verticalGridWithIndexesComposedWithCorrectIndexAndItem() {
+        val items = (0..1).map { it.toString() }
+
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(1), Modifier.height(200.dp)) {
+                itemsIndexed(items) { index, item ->
+                    BasicText(
+                        "${index}x$item", Modifier.requiredHeight(100.dp)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithText("0x0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithText("1x1")
+            .assertTopPositionInRootIsEqualTo(100.dp)
+    }
+
+    // @Test
+    // fun lazyRowShowsIndexedItems() {
+    //     val items = (1..4).map { it.toString() }
+
+    //     rule.setContent {
+    //         LazyRow(Modifier.width(200.dp)) {
+    //             itemsIndexed(items) { index, item ->
+    //                 Spacer(
+    //                     Modifier.width(101.dp).fillParentMaxHeight()
+    //                         .testTag("$index-$item")
+    //                 )
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("0-1")
+    //         .assertIsDisplayed()
+
+    //     rule.onNodeWithTag("1-2")
+    //         .assertIsDisplayed()
+
+    //     rule.onNodeWithTag("2-3")
+    //         .assertDoesNotExist()
+
+    //     rule.onNodeWithTag("3-4")
+    //         .assertDoesNotExist()
+    // }
+
+    // @Test
+    // fun rowWithIndexesComposedWithCorrectIndexAndItem() {
+    //     val items = (0..1).map { it.toString() }
+
+    //     rule.setContent {
+    //         LazyRow(Modifier.width(200.dp)) {
+    //             itemsIndexed(items) { index, item ->
+    //                 BasicText(
+    //                     "${index}x$item", Modifier.fillParentMaxHeight().requiredWidth(100.dp)
+    //                 )
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithText("0x0")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+
+    //     rule.onNodeWithText("1x1")
+    //         .assertLeftPositionInRootIsEqualTo(100.dp)
+    // }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsReverseLayoutTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsReverseLayoutTest.kt
new file mode 100644
index 0000000..c3cd0a9
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyGridsReverseLayoutTest.kt
@@ -0,0 +1,520 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.tv.foundation.lazy.list.setContentWithTestViewConfiguration
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class LazyGridsReverseLayoutTest {
+
+    private val ContainerTag = "ContainerTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var itemSize: Dp = Dp.Infinity
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSize = 50.toDp()
+        }
+    }
+
+    @Test
+    fun verticalGrid_reverseLayout() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(2),
+                Modifier.width(itemSize * 2),
+                reverseLayout = true
+            ) {
+                items(4) {
+                    Box(Modifier.height(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun column_emitTwoElementsAsOneItem() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(2),
+                Modifier.width(itemSize * 2),
+                reverseLayout = true
+            ) {
+                items(4) {
+                    Box(Modifier.height(itemSize).testTag((it * 2).toString()))
+                    Box(Modifier.height(itemSize).testTag((it * 2 + 1).toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("4")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("5")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("6")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("7")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun verticalGrid_initialScrollPositionIs0() {
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(2),
+                reverseLayout = true,
+                state = rememberLazyGridState().also { state = it },
+                modifier = Modifier.size(itemSize * 2).testTag(ContainerTag)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun verticalGrid_scrollInWrongDirectionDoesNothing() {
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                reverseLayout = true,
+                state = rememberLazyGridState().also { state = it },
+                modifier = Modifier.size(itemSize * 2).testTag(ContainerTag)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.size(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // we scroll down and as the scrolling is reversed it shouldn't affect anything
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_UP, 2)
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun verticalGrid_scrollForwardHalfWay() {
+        lateinit var state: TvLazyGridState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                reverseLayout = true,
+                state = rememberLazyGridState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 2)
+
+        val scrolled = rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            with(rule.density) { state.firstVisibleItemScrollOffset.toDp() }
+        }
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(-itemSize + scrolled)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(scrolled)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize + scrolled)
+    }
+
+    // @Test
+    // fun row_emitTwoElementsAsOneItem_positionedReversed() {
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true
+    //         ) {
+    //             item {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("0"))
+    //                 Box(Modifier.requiredSize(itemSize).testTag("1"))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    // }
+
+    // @Test
+    // fun row_emitTwoItems_positionedReversed() {
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true
+    //         ) {
+    //             item {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("0"))
+    //             }
+    //             item {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("1"))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    // }
+
+    // @Test
+    // fun row_initialScrollPositionIs0() {
+    //     lateinit var state: LazyListState
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true,
+    //             state = rememberLazyListState().also { state = it },
+    //             modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag)
+    //         ) {
+    //             items((0..2).toList()) {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("$it"))
+    //             }
+    //         }
+    //     }
+
+    //     rule.runOnIdle {
+    //         assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+    //         assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+    //     }
+    // }
+
+    // @Test
+    // fun row_scrollInWrongDirectionDoesNothing() {
+    //     lateinit var state: LazyListState
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true,
+    //             state = rememberLazyListState().also { state = it },
+    //             modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag)
+    //         ) {
+    //             items((0..2).toList()) {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("$it"))
+    //             }
+    //         }
+    //     }
+
+    //     // we scroll down and as the scrolling is reversed it shouldn't affect anything
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .scrollBy(x = itemSize, density = rule.density)
+
+    //     rule.runOnIdle {
+    //         assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+    //         assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    // }
+
+    // @Test
+    // fun row_scrollForwardHalfWay() {
+    //     lateinit var state: LazyListState
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true,
+    //             state = rememberLazyListState().also { state = it },
+    //             modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag)
+    //         ) {
+    //             items((0..2).toList()) {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("$it"))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .scrollBy(x = -itemSize * 0.5f, density = rule.density)
+
+    //     val scrolled = rule.runOnIdle {
+    //         assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+    //         assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+    //         with(rule.density) { state.firstVisibleItemScrollOffset.toDp() }
+    //     }
+
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(-itemSize + scrolled)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(scrolled)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize + scrolled)
+    // }
+
+    // @Test
+    // fun row_scrollForwardTillTheEnd() {
+    //     lateinit var state: LazyListState
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = true,
+    //             state = rememberLazyListState().also { state = it },
+    //             modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag)
+    //         ) {
+    //             items((0..3).toList()) {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("$it"))
+    //             }
+    //         }
+    //     }
+
+    //     // we scroll a bit more than it is possible just to make sure we would stop correctly
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .scrollBy(x = -itemSize * 2.2f, density = rule.density)
+
+    //     rule.runOnIdle {
+    //         with(rule.density) {
+    //             val realOffset = state.firstVisibleItemScrollOffset.toDp() +
+    //                 itemSize * state.firstVisibleItemIndex
+    //             assertThat(realOffset).isEqualTo(itemSize * 2)
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("3")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    // }
+
+    // @Test
+    // fun row_rtl_emitTwoElementsAsOneItem_positionedReversed() {
+    //     rule.setContentWithTestViewConfiguration {
+    //         CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+    //             LazyRow(
+    //                 reverseLayout = true
+    //             ) {
+    //                 item {
+    //                     Box(Modifier.requiredSize(itemSize).testTag("0"))
+    //                     Box(Modifier.requiredSize(itemSize).testTag("1"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    // }
+
+    // @Test
+    // fun row_rtl_emitTwoItems_positionedReversed() {
+    //     rule.setContentWithTestViewConfiguration {
+    //         CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+    //             LazyRow(
+    //                 reverseLayout = true
+    //             ) {
+    //                 item {
+    //                     Box(Modifier.requiredSize(itemSize).testTag("0"))
+    //                 }
+    //                 item {
+    //                     Box(Modifier.requiredSize(itemSize).testTag("1"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    // }
+
+    // @Test
+    // fun row_rtl_scrollForwardHalfWay() {
+    //     lateinit var state: LazyListState
+    //     rule.setContentWithTestViewConfiguration {
+    //         CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+    //             LazyRow(
+    //                 reverseLayout = true,
+    //                 state = rememberLazyListState().also { state = it },
+    //                 modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag)
+    //             ) {
+    //                 items((0..2).toList()) {
+    //                     Box(Modifier.requiredSize(itemSize).testTag("$it"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(ContainerTag)
+    //         .scrollBy(x = itemSize * 0.5f, density = rule.density)
+
+    //     val scrolled = rule.runOnIdle {
+    //         assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+    //         assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+    //         with(rule.density) { state.firstVisibleItemScrollOffset.toDp() }
+    //     }
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(-scrolled)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize - scrolled)
+    //     rule.onNodeWithTag("2")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize * 2 - scrolled)
+    // }
+
+    @Test
+    fun verticalGrid_whenParameterChanges() {
+        var reverse by mutableStateOf(true)
+        rule.setContentWithTestViewConfiguration {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(2),
+                Modifier.width(itemSize * 2),
+                reverseLayout = reverse
+            ) {
+                items(4) {
+                    Box(Modifier.size(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            reverse = false
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    // @Test
+    // fun row_whenParameterChanges() {
+    //     var reverse by mutableStateOf(true)
+    //     rule.setContentWithTestViewConfiguration {
+    //         LazyRow(
+    //             reverseLayout = reverse
+    //         ) {
+    //             item {
+    //                 Box(Modifier.requiredSize(itemSize).testTag("0"))
+    //                 Box(Modifier.requiredSize(itemSize).testTag("1"))
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+
+    //     rule.runOnIdle {
+    //         reverse = false
+    //     }
+
+    //     rule.onNodeWithTag("0")
+    //         .assertLeftPositionInRootIsEqualTo(0.dp)
+    //     rule.onNodeWithTag("1")
+    //         .assertLeftPositionInRootIsEqualTo(itemSize)
+    // }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyItemStateRestoration.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyItemStateRestoration.kt
new file mode 100644
index 0000000..f4e519e
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyItemStateRestoration.kt
@@ -0,0 +1,319 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.StateRestorationTester
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyRow
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+
+class LazyItemStateRestoration {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun visibleItemsStateRestored() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        var counter2 = 100
+        var realState = arrayOf(0, 0, 0)
+        restorationTester.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(1)) {
+                item {
+                    realState[0] = rememberSaveable { counter0++ }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+                items((1..2).toList()) {
+                    if (it == 1) {
+                        realState[1] = rememberSaveable { counter1++ }
+                    } else {
+                        realState[2] = rememberSaveable { counter2++ }
+                    }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+            realState = arrayOf(0, 0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+        }
+    }
+
+    @Test
+    fun itemsStateRestoredWhenWeScrolledBackToIt() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        lateinit var state: TvLazyGridState
+        var itemDisposed = false
+        var realState = 0
+        restorationTester.setContent {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.requiredSize(20.dp),
+                state = rememberLazyGridState().also { state = it }
+            ) {
+                items((0..10).toList()) {
+                    if (it == 0) {
+                        realState = rememberSaveable { counter0++ }
+                        DisposableEffect(Unit) {
+                            onDispose {
+                                itemDisposed = true
+                            }
+                        }
+                    }
+                    Box(Modifier.requiredSize(30.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+            runBlocking {
+                // we scroll through multiple items to make sure the 0th element is not kept in
+                // the reusable items buffer
+                state.scrollToItem(3)
+                state.scrollToItem(5)
+                state.scrollToItem(8)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(itemDisposed).isEqualTo(true)
+            realState = 0
+            runBlocking {
+                state.scrollToItem(0, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun itemsStateRestoredWhenWeScrolledRestoredAndScrolledBackTo() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        lateinit var state: TvLazyGridState
+        var realState = arrayOf(0, 0)
+        restorationTester.setContent {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.requiredSize(20.dp),
+                state = rememberLazyGridState().also { state = it }
+            ) {
+                items((0..1).toList()) {
+                    if (it == 0) {
+                        realState[0] = rememberSaveable { counter0++ }
+                    } else {
+                        realState[1] = rememberSaveable { counter1++ }
+                    }
+                    Box(Modifier.requiredSize(30.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            runBlocking {
+                state.scrollToItem(1, 5)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[1]).isEqualTo(10)
+            realState = arrayOf(0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[1]).isEqualTo(10)
+            runBlocking {
+                state.scrollToItem(0, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun nestedLazy_itemsStateRestoredWhenWeScrolledBackToIt() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        lateinit var state: TvLazyGridState
+        var itemDisposed = false
+        var realState = 0
+        restorationTester.setContent {
+            TvLazyVerticalGrid(
+                TvGridCells.Fixed(1),
+                Modifier.requiredSize(20.dp),
+                state = rememberLazyGridState().also { state = it }
+            ) {
+                items((0..10).toList()) {
+                    if (it == 0) {
+                        TvLazyRow {
+                            item {
+                                realState = rememberSaveable { counter0++ }
+                                DisposableEffect(Unit) {
+                                    onDispose {
+                                        itemDisposed = true
+                                    }
+                                }
+                                Box(Modifier.requiredSize(30.dp))
+                            }
+                        }
+                    } else {
+                        Box(Modifier.requiredSize(30.dp))
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+            runBlocking {
+                // we scroll through multiple items to make sure the 0th element is not kept in
+                // the reusable items buffer
+                state.scrollToItem(3)
+                state.scrollToItem(5)
+                state.scrollToItem(8)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(itemDisposed).isEqualTo(true)
+            realState = 0
+            runBlocking {
+                state.scrollToItem(0, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun stateRestoredWhenUsedWithCustomKeys() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        var counter2 = 100
+        var realState = arrayOf(0, 0, 0)
+        restorationTester.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(1)) {
+                items(3, key = { "$it" }) {
+                    if (it == 0) {
+                        realState[0] = rememberSaveable { counter0++ }
+                    } else if (it == 1) {
+                        realState[1] = rememberSaveable { counter1++ }
+                    } else {
+                        realState[2] = rememberSaveable { counter2++ }
+                    }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+            realState = arrayOf(0, 0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+        }
+    }
+
+    @Test
+    fun stateRestoredWhenUsedWithCustomKeysAfterReordering() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        var counter2 = 100
+        var realState = arrayOf(0, 0, 0)
+        var list by mutableStateOf(listOf(0, 1, 2))
+        restorationTester.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(1)) {
+                items(list, key = { "$it" }) {
+                    if (it == 0) {
+                        realState[0] = rememberSaveable { counter0++ }
+                    } else if (it == 1) {
+                        realState[1] = rememberSaveable { counter1++ }
+                    } else {
+                        realState[2] = rememberSaveable { counter2++ }
+                    }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2)
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+            realState = arrayOf(0, 0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(0)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyNestedScrollingTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyNestedScrollingTest.kt
new file mode 100644
index 0000000..404218a
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyNestedScrollingTest.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.tv.foundation.lazy.list.TestTouchSlop
+import androidx.tv.foundation.lazy.list.setContentWithTestViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazyNestedScrollingTest {
+    private val LazyTag = "LazyTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val expectedDragOffset = 20f
+    private val dragOffsetWithTouchSlop = expectedDragOffset + TestTouchSlop
+
+    @Test
+    fun verticalGrid_nestedScrollingBackwardInitially() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    Modifier.requiredSize(100.dp).testTag(LazyTag)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(50.dp).testTag("$it"))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = 100f + TestTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(100f)
+        }
+    }
+
+    @Test
+    fun verticalGrid_nestedScrollingBackwardOnceWeScrolledForwardPreviously() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    Modifier.requiredSize(100.dp).testTag(LazyTag),
+                ) {
+                    items(items) {
+                        Box(Modifier.requiredHeight(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        // scroll forward
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 2)
+
+        // scroll back so we again on 0 position
+        // we scroll one extra dp to prevent rounding issues
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_UP, 2)
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                draggedOffset = 0f
+                down(Offset(x = 100f, y = 100f))
+                moveBy(Offset(x = 0f, y = dragOffsetWithTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun verticalGrid_nestedScrollingForwardWhenTheFullContentIsInitiallyVisible() = runBlocking {
+        val items = (1..2).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    Modifier.requiredSize(100.dp).testTag(LazyTag)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(40.dp).testTag("$it"))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = -dragOffsetWithTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun verticalGrid_nestedScrollingForwardWhenScrolledToTheEnd() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyVerticalGrid(
+                    TvGridCells.Fixed(1),
+                    Modifier.requiredSize(100.dp).testTag(LazyTag)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        // scroll till the end
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 2)
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                draggedOffset = 0f
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = -dragOffsetWithTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+        }
+    }
+
+    // @Test
+    // fun row_nestedScrollingBackwardInitially() = runBlocking {
+    //     val items = (1..3).toList()
+    //     var draggedOffset = 0f
+    //     val scrollable = ScrollableState {
+    //         draggedOffset += it
+    //         it
+    //     }
+    //     rule.setContentWithTestViewConfiguration {
+    //         Box(
+    //             Modifier.scrollable(
+    //                 orientation = Orientation.Horizontal,
+    //                 state = scrollable
+    //             )
+    //         ) {
+    //             LazyRow(
+    //                 modifier = Modifier.requiredSize(100.dp).testTag(LazyTag)
+    //             ) {
+    //                 items(items) {
+    //                     Spacer(Modifier.requiredSize(50.dp).testTag("$it"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(LazyTag)
+    //         .performTouchInput {
+    //             down(Offset(x = 10f, y = 10f))
+    //             moveBy(Offset(x = dragOffsetWithTouchSlop, y = 0f))
+    //             up()
+    //         }
+
+    //     rule.runOnIdle {
+    //         Truth.assertThat(draggedOffset).isEqualTo(expectedDragOffset)
+    //     }
+    // }
+
+    // @Test
+    // fun row_nestedScrollingBackwardOnceWeScrolledForwardPreviously() = runBlocking {
+    //     val items = (1..3).toList()
+    //     var draggedOffset = 0f
+    //     val scrollable = ScrollableState {
+    //         draggedOffset += it
+    //         it
+    //     }
+    //     rule.setContentWithTestViewConfiguration {
+    //         Box(
+    //             Modifier.scrollable(
+    //                 orientation = Orientation.Horizontal,
+    //                 state = scrollable
+    //             )
+    //         ) {
+    //             LazyRow(
+    //                 modifier = Modifier.requiredSize(100.dp).testTag(LazyTag)
+    //             ) {
+    //                 items(items) {
+    //                     Spacer(Modifier.requiredSize(50.dp).testTag("$it"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     // scroll forward
+    //     rule.onNodeWithTag(LazyTag)
+    //         .scrollBy(x = 20.dp, density = rule.density)
+
+    //     // scroll back so we again on 0 position
+    //     // we scroll one extra dp to prevent rounding issues
+    //     rule.onNodeWithTag(LazyTag)
+    //         .scrollBy(x = -(21.dp), density = rule.density)
+
+    //     rule.onNodeWithTag(LazyTag)
+    //         .performTouchInput {
+    //             draggedOffset = 0f
+    //             down(Offset(x = 10f, y = 10f))
+    //             moveBy(Offset(x = dragOffsetWithTouchSlop, y = 0f))
+    //             up()
+    //         }
+
+    //     rule.runOnIdle {
+    //         Truth.assertThat(draggedOffset).isEqualTo(expectedDragOffset)
+    //     }
+    // }
+
+    // @Test
+    // fun row_nestedScrollingForwardWhenTheFullContentIsInitiallyVisible() = runBlocking {
+    //     val items = (1..2).toList()
+    //     var draggedOffset = 0f
+    //     val scrollable = ScrollableState {
+    //         draggedOffset += it
+    //         it
+    //     }
+    //     rule.setContentWithTestViewConfiguration {
+    //         Box(
+    //             Modifier.scrollable(
+    //                 orientation = Orientation.Horizontal,
+    //                 state = scrollable
+    //             )
+    //         ) {
+    //             LazyRow(
+    //                 modifier = Modifier.requiredSize(100.dp).testTag(LazyTag)
+    //             ) {
+    //                 items(items) {
+    //                     Spacer(Modifier.requiredSize(40.dp).testTag("$it"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     rule.onNodeWithTag(LazyTag)
+    //         .performTouchInput {
+    //             down(Offset(x = 10f, y = 10f))
+    //             moveBy(Offset(x = -dragOffsetWithTouchSlop, y = 0f))
+    //             up()
+    //         }
+
+    //     rule.runOnIdle {
+    //         Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+    //     }
+    // }
+
+    // @Test
+    // fun row_nestedScrollingForwardWhenScrolledToTheEnd() = runBlocking {
+    //     val items = (1..3).toList()
+    //     var draggedOffset = 0f
+    //     val scrollable = ScrollableState {
+    //         draggedOffset += it
+    //         it
+    //     }
+    //     rule.setContentWithTestViewConfiguration {
+    //         Box(
+    //             Modifier.scrollable(
+    //                 orientation = Orientation.Horizontal,
+    //                 state = scrollable
+    //             )
+    //         ) {
+    //             LazyRow(
+    //                 modifier = Modifier.requiredSize(100.dp).testTag(LazyTag)
+    //             ) {
+    //                 items(items) {
+    //                     Spacer(Modifier.requiredSize(50.dp).testTag("$it"))
+    //                 }
+    //             }
+    //         }
+    //     }
+
+    //     // scroll till the end
+    //     rule.onNodeWithTag(LazyTag)
+    //         .scrollBy(x = 55.dp, density = rule.density)
+
+    //     rule.onNodeWithTag(LazyTag)
+    //         .performTouchInput {
+    //             draggedOffset = 0f
+    //             down(Offset(x = 10f, y = 10f))
+    //             moveBy(Offset(x = -dragOffsetWithTouchSlop, y = 0f))
+    //             up()
+    //         }
+
+    //     rule.runOnIdle {
+    //         Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+    //     }
+    // }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyScrollAccessibilityTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyScrollAccessibilityTest.kt
new file mode 100644
index 0000000..8af2b8d
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyScrollAccessibilityTest.kt
@@ -0,0 +1,344 @@
+/*
+ * Copyright 2022 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 androidx.tv.compose.foundation.lazy.grid
+
+import android.R.id.accessibilityActionScrollDown
+import android.R.id.accessibilityActionScrollLeft
+import android.R.id.accessibilityActionScrollRight
+import android.R.id.accessibilityActionScrollUp
+import android.view.View
+import android.view.accessibility.AccessibilityNodeProvider
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.core.view.ViewCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
+import androidx.test.filters.MediumTest
+import com.google.common.truth.IterableSubject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+class LazyScrollAccessibilityTest(
+    private val config: TestConfig
+) : BaseLazyGridTestWithOrientation(config.orientation) {
+
+    data class TestConfig(
+        val orientation: Orientation,
+        val rtl: Boolean,
+        val reversed: Boolean
+    ) {
+        val horizontal = orientation == Orientation.Horizontal
+        val vertical = !horizontal
+
+        override fun toString(): String {
+            return (if (orientation == Orientation.Horizontal) "horizontal" else "vertical") +
+                (if (rtl) ",rtl" else ",ltr") +
+                (if (reversed) ",reversed" else "")
+        }
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() =
+            listOf(Orientation.Horizontal, Orientation.Vertical).flatMap { horizontal ->
+                listOf(false, true).flatMap { rtl ->
+                    listOf(false, true).map { reversed ->
+                        TestConfig(horizontal, rtl, reversed)
+                    }
+                }
+            }
+    }
+
+    private val scrollerTag = "ScrollerTest"
+    private var composeView: View? = null
+    private val accessibilityNodeProvider: AccessibilityNodeProvider
+        get() = checkNotNull(composeView) {
+            "composeView not initialized. Did `composeView = LocalView.current` not work?"
+        }.let { composeView ->
+            ViewCompat
+                .getAccessibilityDelegate(composeView)!!
+                .getAccessibilityNodeProvider(composeView)!!
+                .provider as AccessibilityNodeProvider
+        }
+
+    @Test
+    fun scrollForward() {
+        testRelativeDirection(58, ACTION_SCROLL_FORWARD)
+    }
+
+    @Test
+    fun scrollBackward() {
+        testRelativeDirection(41, ACTION_SCROLL_BACKWARD)
+    }
+
+    @Test
+    fun scrollRight() {
+        testAbsoluteDirection(58, accessibilityActionScrollRight, config.horizontal)
+    }
+
+    @Test
+    fun scrollLeft() {
+        testAbsoluteDirection(41, accessibilityActionScrollLeft, config.horizontal)
+    }
+
+    @Test
+    fun scrollDown() {
+        testAbsoluteDirection(58, accessibilityActionScrollDown, config.vertical)
+    }
+
+    @Test
+    fun scrollUp() {
+        testAbsoluteDirection(41, accessibilityActionScrollUp, config.vertical)
+    }
+
+    @Test
+    fun verifyScrollActionsAtStart() {
+        createScrollableContent_StartAtStart()
+        verifyNodeInfoScrollActions(
+            expectForward = !config.reversed,
+            expectBackward = config.reversed
+        )
+    }
+
+    @Test
+    fun verifyScrollActionsInMiddle() {
+        createScrollableContent_StartInMiddle()
+        verifyNodeInfoScrollActions(
+            expectForward = true,
+            expectBackward = true
+        )
+    }
+
+    @Test
+    fun verifyScrollActionsAtEnd() {
+        createScrollableContent_StartAtEnd()
+        verifyNodeInfoScrollActions(
+            expectForward = config.reversed,
+            expectBackward = !config.reversed
+        )
+    }
+
+    /**
+     * Setup the test, run the given [accessibilityAction], and check if the [canonicalTarget]
+     * has been reached. The canonical target is the item that we expect to see when moving
+     * forward in a non-reversed scrollable (e.g. down in LazyColumn or right in LazyRow in LTR).
+     * The actual target is either the canonical target or the target that is as far from the
+     * middle of the lazy list as the canonical target, but on the other side of the middle,
+     * depending on the [configuration][config].
+     */
+    private fun testRelativeDirection(canonicalTarget: Int, accessibilityAction: Int) {
+        val target = if (!config.reversed) canonicalTarget else 100 - canonicalTarget - 1
+        testScrollAction(target, accessibilityAction)
+    }
+
+    /**
+     * Setup the test, run the given [accessibilityAction], and check if the [canonicalTarget]
+     * has been reached (but only if we [expect][expectActionSuccess] the action to succeed).
+     * The canonical target is the item that we expect to see when moving forward in a
+     * non-reversed scrollable (e.g. down in LazyColumn or right in LazyRow in LTR). The actual
+     * target is either the canonical target or the target that is as far from the middle of the
+     * scrollable as the canonical target, but on the other side of the middle, depending on the
+     * [configuration][config].
+     */
+    private fun testAbsoluteDirection(
+        canonicalTarget: Int,
+        accessibilityAction: Int,
+        expectActionSuccess: Boolean
+    ) {
+        var target = canonicalTarget
+        if (config.horizontal && config.rtl) {
+            target = 100 - target - 1
+        }
+        if (config.reversed) {
+            target = 100 - target - 1
+        }
+        testScrollAction(target, accessibilityAction, expectActionSuccess)
+    }
+
+    /**
+     * Setup the test, run the given [accessibilityAction], and check if the [target] has been
+     * reached (but only if we [expect][expectActionSuccess] the action to succeed).
+     */
+    private fun testScrollAction(
+        target: Int,
+        accessibilityAction: Int,
+        expectActionSuccess: Boolean = true
+    ) {
+        createScrollableContent_StartInMiddle()
+        rule.onNodeWithText("$target").assertDoesNotExist()
+
+        val returnValue = rule.onNodeWithTag(scrollerTag).withSemanticsNode {
+            accessibilityNodeProvider.performAction(id, accessibilityAction, null)
+        }
+
+        assertThat(returnValue).isEqualTo(expectActionSuccess)
+        if (expectActionSuccess) {
+            rule.onNodeWithText("$target").assertIsDisplayed()
+        } else {
+            rule.onNodeWithText("$target").assertDoesNotExist()
+        }
+    }
+
+    /**
+     * Checks if all of the scroll actions are present or not according to what we expect based on
+     * [expectForward] and [expectBackward]. The scroll actions that are checked are forward,
+     * backward, left, right, up and down. The expectation parameters must already account for
+     * [reversing][TestConfig.reversed].
+     */
+    private fun verifyNodeInfoScrollActions(expectForward: Boolean, expectBackward: Boolean) {
+        val nodeInfo = rule.onNodeWithTag(scrollerTag).withSemanticsNode {
+            rule.runOnUiThread {
+                accessibilityNodeProvider.createAccessibilityNodeInfo(id)
+            }
+        }
+
+        val actions = nodeInfo.actionList.map { it.id }
+
+        assertThat(actions).contains(expectForward, ACTION_SCROLL_FORWARD)
+        assertThat(actions).contains(expectBackward, ACTION_SCROLL_BACKWARD)
+
+        if (config.horizontal) {
+            val expectLeft = if (config.rtl) expectForward else expectBackward
+            val expectRight = if (config.rtl) expectBackward else expectForward
+            assertThat(actions).contains(expectLeft, accessibilityActionScrollLeft)
+            assertThat(actions).contains(expectRight, accessibilityActionScrollRight)
+            assertThat(actions).contains(false, accessibilityActionScrollDown)
+            assertThat(actions).contains(false, accessibilityActionScrollUp)
+        } else {
+            assertThat(actions).contains(false, accessibilityActionScrollLeft)
+            assertThat(actions).contains(false, accessibilityActionScrollRight)
+            assertThat(actions).contains(expectForward, accessibilityActionScrollDown)
+            assertThat(actions).contains(expectBackward, accessibilityActionScrollUp)
+        }
+    }
+
+    private fun IterableSubject.contains(expectPresent: Boolean, element: Any) {
+        if (expectPresent) {
+            contains(element)
+        } else {
+            doesNotContain(element)
+        }
+    }
+
+    /**
+     * Creates a Row/Column that starts at the first item, according to [createScrollableContent]
+     */
+    private fun createScrollableContent_StartAtStart() {
+        createScrollableContent {
+            // Start at the start:
+            // -> pretty basic
+            rememberLazyGridState(0, 0)
+        }
+    }
+
+    /**
+     * Creates a Row/Column that starts in the middle, according to [createScrollableContent]
+     */
+    private fun createScrollableContent_StartInMiddle() {
+        createScrollableContent {
+            // Start at the middle:
+            // Content size: 100 items * 21dp per item = 2100dp
+            // Viewport size: 200dp rect - 50dp padding on both sides = 100dp
+            // Content outside viewport: 2100dp - 100dp = 2000dp
+            // -> centered when 1000dp on either side, which is 47 items + 13dp
+            rememberLazyGridState(
+                47,
+                with(LocalDensity.current) { 13.dp.roundToPx() }
+            )
+        }
+    }
+
+    /**
+     * Creates a Row/Column that starts at the last item, according to [createScrollableContent]
+     */
+    private fun createScrollableContent_StartAtEnd() {
+        createScrollableContent {
+            // Start at the end:
+            // Content size: 100 items * 21dp per item = 2100dp
+            // Viewport size: 200dp rect - 50dp padding on both sides = 100dp
+            // Content outside viewport: 2100dp - 100dp = 2000dp
+            // -> at the end when offset at 2000dp, which is 95 items + 5dp
+            rememberLazyGridState(
+                95,
+                with(LocalDensity.current) { 5.dp.roundToPx() }
+            )
+        }
+    }
+
+    /**
+     * Creates a grid with a viewport of 100.dp, containing 100 items each 17.dp in size.
+     * The items have a text with their index (ASC), and where the viewport starts is determined
+     * by the given [lambda][rememberTvLazyGridState]. All properties from [config] are applied.
+     * The viewport has padding around it to make sure scroll distance doesn't include padding.
+     */
+    private fun createScrollableContent(
+        rememberTvLazyGridState: @Composable () -> TvLazyGridState
+    ) {
+        rule.setContent {
+            composeView = LocalView.current
+
+            val state = rememberTvLazyGridState()
+
+            Box(Modifier.requiredSize(200.dp).background(Color.White)) {
+                val direction = if (config.rtl) LayoutDirection.Rtl else LayoutDirection.Ltr
+                CompositionLocalProvider(LocalLayoutDirection provides direction) {
+                    LazyGrid(
+                        cells = 1,
+                        modifier = Modifier.testTag(scrollerTag).matchParentSize(),
+                        state = state,
+                        contentPadding = PaddingValues(50.dp),
+                        reverseLayout = config.reversed
+                    ) {
+                        items(100) {
+                            Box(Modifier.requiredSize(21.dp).background(Color.Yellow)) {
+                                BasicText("$it", Modifier.align(Alignment.Center))
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun <T> SemanticsNodeInteraction.withSemanticsNode(block: SemanticsNode.() -> T): T {
+        return block.invoke(fetchSemanticsNode())
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyScrollTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyScrollTest.kt
new file mode 100644
index 0000000..dc794c5
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazyScrollTest.kt
@@ -0,0 +1,336 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.animation.core.FloatSpringSpec
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Dp
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.concurrent.TimeUnit
+import kotlin.math.roundToInt
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@MediumTest
+// @RunWith(Parameterized::class)
+class LazyScrollTest { // (private val orientation: Orientation)
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val vertical: Boolean
+        get() = true // orientation == Orientation.Vertical
+
+    private val itemsCount = 40
+    private lateinit var state: TvLazyGridState
+
+    private val itemSizePx = 100
+    private var itemSizeDp = Dp.Unspecified
+    private var containerSizeDp = Dp.Unspecified
+
+    lateinit var scope: CoroutineScope
+
+    @Before
+    fun setup() {
+        with(rule.density) {
+            itemSizeDp = itemSizePx.toDp()
+            containerSizeDp = itemSizeDp * 3
+        }
+        rule.setContent {
+            state = rememberLazyGridState()
+            scope = rememberCoroutineScope()
+            TestContent()
+        }
+    }
+
+    @Test
+    fun setupWorks() {
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+    }
+
+    @Test
+    fun scrollToItem() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(2)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(0)
+            state.scrollToItem(3)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+    }
+
+    @Test
+    fun scrollToItemWithOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(6, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(6)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+    }
+
+    @Test
+    fun scrollToItemWithNegativeOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(6, -10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(4)
+        val item6Offset = state.layoutInfo.visibleItemsInfo.first { it.index == 6 }.offset.y
+        assertThat(item6Offset).isEqualTo(10)
+    }
+
+    @Test
+    fun scrollToItemWithPositiveOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(itemsCount - 6, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 6)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not 10
+    }
+
+    @Test
+    fun scrollToItemWithNegativeOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(1, -(itemSizePx + 10))
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not -10
+    }
+
+    @Test
+    fun scrollToItemWithIndexLargerThanItemsCount() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(itemsCount + 4)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 6)
+    }
+
+    @Test
+    fun animateScrollBy() = runBlocking {
+        val scrollDistance = 320
+
+        val expectedLine = scrollDistance / itemSizePx // resolves to 3
+        val expectedItem = expectedLine * 2 // resolves to 6
+        val expectedOffset = scrollDistance % itemSizePx // resolves to 20px
+
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollBy(scrollDistance.toFloat())
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(expectedItem)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
+    }
+
+    @Test
+    fun animateScrollToItem() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(10, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(10)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+    }
+
+    @Test
+    fun animateScrollToItemWithOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(6, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(6)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+    }
+
+    @Test
+    fun animateScrollToItemWithNegativeOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(6, -10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(4)
+        val item6Offset = state.layoutInfo.visibleItemsInfo.first { it.index == 6 }.offset.y
+        assertThat(item6Offset).isEqualTo(10)
+    }
+
+    @Test
+    fun animateScrollToItemWithPositiveOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(itemsCount - 6, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 6)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not 10
+    }
+
+    @Test
+    fun animateScrollToItemWithNegativeOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(2, -(itemSizePx + 10))
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not -10
+    }
+
+    @Test
+    fun animateScrollToItemWithIndexLargerThanItemsCount() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(itemsCount + 2)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 6)
+    }
+
+    @Test
+    fun animatePerFrameForwardToVisibleItem() {
+        assertSpringAnimation(toIndex = 4)
+    }
+
+    @Test
+    fun animatePerFrameForwardToVisibleItemWithOffset() {
+        assertSpringAnimation(toIndex = 4, toOffset = 35)
+    }
+
+    @Test
+    fun animatePerFrameForwardToNotVisibleItem() {
+        assertSpringAnimation(toIndex = 16)
+    }
+
+    @Test
+    fun animatePerFrameForwardToNotVisibleItemWithOffset() {
+        assertSpringAnimation(toIndex = 20, toOffset = 35)
+    }
+
+    @Test
+    fun animatePerFrameBackward() {
+        assertSpringAnimation(toIndex = 2, fromIndex = 12)
+    }
+
+    @Test
+    fun animatePerFrameBackwardWithOffset() {
+        assertSpringAnimation(toIndex = 2, fromIndex = 10, fromOffset = 58)
+    }
+
+    @Test
+    fun animatePerFrameBackwardWithInitialOffset() {
+        assertSpringAnimation(toIndex = 0, toOffset = 40, fromIndex = 8)
+    }
+
+    private fun assertSpringAnimation(
+        toIndex: Int,
+        toOffset: Int = 0,
+        fromIndex: Int = 0,
+        fromOffset: Int = 0
+    ) {
+        if (fromIndex != 0 || fromOffset != 0) {
+            rule.runOnIdle {
+                runBlocking {
+                    state.scrollToItem(fromIndex, fromOffset)
+                }
+            }
+        }
+        rule.waitForIdle()
+
+        assertThat(state.firstVisibleItemIndex).isEqualTo(fromIndex)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(fromOffset)
+
+        rule.mainClock.autoAdvance = false
+
+        scope.launch {
+            state.animateScrollToItem(toIndex, toOffset)
+        }
+
+        while (!state.isScrollInProgress) {
+            Thread.sleep(5)
+        }
+
+        val startOffset = (fromIndex / 2 * itemSizePx + fromOffset).toFloat()
+        val endOffset = (toIndex / 2 * itemSizePx + toOffset).toFloat()
+        val spec = FloatSpringSpec()
+
+        val duration =
+            TimeUnit.NANOSECONDS.toMillis(spec.getDurationNanos(startOffset, endOffset, 0f))
+        rule.mainClock.advanceTimeByFrame()
+        var expectedTime = rule.mainClock.currentTime
+        for (i in 0..duration step FrameDuration) {
+            val nanosTime = TimeUnit.MILLISECONDS.toNanos(i)
+            val expectedValue =
+                spec.getValueFromNanos(nanosTime, startOffset, endOffset, 0f)
+            val actualValue =
+                (state.firstVisibleItemIndex / 2 * itemSizePx + state.firstVisibleItemScrollOffset)
+            assertWithMessage(
+                "On animation frame at $i index=${state.firstVisibleItemIndex} " +
+                    "offset=${state.firstVisibleItemScrollOffset} expectedValue=$expectedValue"
+            ).that(actualValue).isEqualTo(expectedValue.roundToInt(), tolerance = 1)
+
+            rule.mainClock.advanceTimeBy(FrameDuration)
+            expectedTime += FrameDuration
+            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            rule.waitForIdle()
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(toIndex)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(toOffset)
+    }
+
+    @Composable
+    private fun TestContent() {
+        if (vertical) {
+            TvLazyVerticalGrid(TvGridCells.Fixed(2), Modifier.height(containerSizeDp), state) {
+                items(itemsCount) {
+                    ItemContent()
+                }
+            }
+        } else {
+            // LazyRow(Modifier.width(300.dp), state) {
+            //     items(items) {
+            //         ItemContent()
+            //     }
+            // }
+        }
+    }
+
+    @Composable
+    private fun ItemContent() {
+        val modifier = if (vertical) {
+            Modifier.height(itemSizeDp)
+        } else {
+            Modifier.width(itemSizeDp)
+        }
+        Spacer(modifier)
+    }
+
+    // companion object {
+    //     @JvmStatic
+    //     @Parameterized.Parameters(name = "{0}")
+    //     fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
+    // }
+}
+
+private val FrameDuration = 16L
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazySemanticsTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazySemanticsTest.kt
new file mode 100644
index 0000000..68c75ee
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/LazySemanticsTest.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions.ScrollToIndex
+import androidx.compose.ui.semantics.SemanticsProperties.IndexForKey
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests the semantics properties defined on a LazyGrid:
+ * - GetIndexForKey
+ * - ScrollToIndex
+ *
+ * GetIndexForKey:
+ * Create a lazy grid, iterate over all indices, verify key of each of them
+ *
+ * ScrollToIndex:
+ * Create a lazy grid, scroll to a line off screen, verify shown items
+ *
+ * All tests performed in [runTest], scenarios set up in the test methods.
+ */
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazySemanticsTest {
+    private val N = 20
+    private val LazyGridTag = "lazy_grid"
+    private val LazyGridModifier = Modifier.testTag(LazyGridTag).requiredSize(100.dp)
+
+    private fun tag(index: Int): String = "tag_$index"
+    private fun key(index: Int): String = "key_$index"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun itemSemantics_verticalGrid() {
+        rule.setContent {
+            TvLazyVerticalGrid(TvGridCells.Fixed(1), LazyGridModifier) {
+                repeat(N) {
+                    item(key = key(it)) {
+                        SpacerInColumn(it)
+                    }
+                }
+            }
+        }
+        runTest()
+    }
+
+    @Test
+    fun itemsSemantics_verticalGrid() {
+        rule.setContent {
+            val state = rememberLazyGridState()
+            TvLazyVerticalGrid(TvGridCells.Fixed(1), LazyGridModifier, state) {
+                items(items = List(N) { it }, key = { key(it) }) {
+                    SpacerInColumn(it)
+                }
+            }
+        }
+        runTest()
+    }
+
+    // @Test
+    // fun itemSemantics_row() {
+    //     rule.setContent {
+    //         LazyRow(LazyGridModifier) {
+    //             repeat(N) {
+    //                 item(key = key(it)) {
+    //                     SpacerInRow(it)
+    //                 }
+    //             }
+    //         }
+    //     }
+    //     runTest()
+    // }
+
+    // @Test
+    // fun itemsSemantics_row() {
+    //     rule.setContent {
+    //         LazyRow(LazyGridModifier) {
+    //             items(items = List(N) { it }, key = { key(it) }) {
+    //                 SpacerInRow(it)
+    //             }
+    //         }
+    //     }
+    //     runTest()
+    // }
+
+    private fun runTest() {
+        checkViewport(firstExpectedItem = 0, lastExpectedItem = 3)
+
+        // Verify IndexForKey
+        rule.onNodeWithTag(LazyGridTag).assert(
+            SemanticsMatcher.keyIsDefined(IndexForKey).and(
+                SemanticsMatcher("keys match") { node ->
+                    val actualIndex = node.config.getOrNull(IndexForKey)!!
+                    (0 until N).all { expectedIndex ->
+                        expectedIndex == actualIndex.invoke(key(expectedIndex))
+                    }
+                }
+            )
+        )
+
+        // Verify ScrollToIndex
+        rule.onNodeWithTag(LazyGridTag).assert(SemanticsMatcher.keyIsDefined(ScrollToIndex))
+
+        invokeScrollToIndex(targetIndex = 10)
+        checkViewport(firstExpectedItem = 10, lastExpectedItem = 13)
+
+        invokeScrollToIndex(targetIndex = N - 1)
+        checkViewport(firstExpectedItem = N - 4, lastExpectedItem = N - 1)
+    }
+
+    private fun invokeScrollToIndex(targetIndex: Int) {
+        val node = rule.onNodeWithTag(LazyGridTag)
+            .fetchSemanticsNode("Failed: invoke ScrollToIndex")
+        rule.runOnUiThread {
+            node.config[ScrollToIndex].action!!.invoke(targetIndex)
+        }
+    }
+
+    private fun checkViewport(firstExpectedItem: Int, lastExpectedItem: Int) {
+        if (firstExpectedItem > 0) {
+            rule.onNodeWithTag(tag(firstExpectedItem - 1)).assertDoesNotExist()
+        }
+        (firstExpectedItem..lastExpectedItem).forEach {
+            rule.onNodeWithTag(tag(it)).assertExists()
+        }
+        if (firstExpectedItem < N - 1) {
+            rule.onNodeWithTag(tag(lastExpectedItem + 1)).assertDoesNotExist()
+        }
+    }
+
+    @Composable
+    private fun SpacerInColumn(index: Int) {
+        Spacer(Modifier.testTag(tag(index)).requiredHeight(30.dp).fillMaxWidth())
+    }
+
+    @Composable
+    private fun SpacerInRow(index: Int) {
+        Spacer(Modifier.testTag(tag(index)).requiredWidth(30.dp).fillMaxHeight())
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/TvLazyGridLayoutInfoTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/TvLazyGridLayoutInfoTest.kt
new file mode 100644
index 0000000..d2bee39
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/grid/TvLazyGridLayoutInfoTest.kt
@@ -0,0 +1,520 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.grid
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.tv.foundation.lazy.list.LayoutInfoTestParam
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+class TvLazyGridLayoutInfoTest(
+    param: LayoutInfoTestParam
+) : BaseLazyGridTestWithOrientation(param.orientation) {
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun initParameters(): Array<Any> = arrayOf(
+            LayoutInfoTestParam(Orientation.Vertical, false),
+            LayoutInfoTestParam(Orientation.Vertical, true),
+            LayoutInfoTestParam(Orientation.Horizontal, false),
+            LayoutInfoTestParam(Orientation.Horizontal, true),
+        )
+    }
+    private val isVertical = param.orientation == Orientation.Vertical
+    private val reverseLayout = param.reverseLayout
+
+    private var itemSizePx: Int = 50
+    private var itemSizeDp: Dp = Dp.Infinity
+    private var gridWidthPx: Int = itemSizePx * 2
+    private var gridWidthDp: Dp = Dp.Infinity
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSizeDp = itemSizePx.toDp()
+            gridWidthDp = gridWidthPx.toDp()
+        }
+    }
+
+    @Test
+    fun visibleItemsAreCorrect() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.axisSize(gridWidthDp, itemSizeDp * 3.5f),
+            ) {
+                items((0..11).toList()) {
+                    Box(Modifier.mainAxisSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            state.layoutInfo.assertVisibleItems(count = 8, cells = 2)
+        }
+    }
+
+    @Test
+    fun visibleItemsAreCorrectAfterScroll() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.axisSize(gridWidthDp, itemSizeDp * 3.5f),
+            ) {
+                items((0..11).toList()) {
+                    Box(Modifier.mainAxisSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2, 10)
+            }
+            state.layoutInfo
+                .assertVisibleItems(count = 8, startIndex = 2, startOffset = -10, cells = 2)
+        }
+    }
+
+    @Test
+    fun visibleItemsAreCorrectWithSpacing() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 1,
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                mainAxisSpacedBy = itemSizeDp,
+                modifier = Modifier.axisSize(itemSizeDp, itemSizeDp * 3.5f),
+            ) {
+                items((0..11).toList()) {
+                    Box(Modifier.mainAxisSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            state.layoutInfo.assertVisibleItems(count = 2, spacing = itemSizePx, cells = 1)
+        }
+    }
+
+    @Composable
+    fun ObservingFun(state: TvLazyGridState, currentInfo: StableRef<TvLazyGridLayoutInfo?>) {
+        currentInfo.value = state.layoutInfo
+    }
+    @Test
+    fun visibleItemsAreObservableWhenWeScroll() {
+        lateinit var state: TvLazyGridState
+        val currentInfo = StableRef<TvLazyGridLayoutInfo?>(null)
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.axisSize(itemSizeDp * 2f, itemSizeDp * 3.5f),
+            ) {
+                items((0..11).toList()) {
+                    Box(Modifier.mainAxisSize(itemSizeDp))
+                }
+            }
+            ObservingFun(state, currentInfo)
+        }
+
+        rule.runOnIdle {
+            // empty it here and scrolling should invoke observingFun again
+            currentInfo.value = null
+            runBlocking {
+                state.scrollToItem(2, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(currentInfo.value).isNotNull()
+            currentInfo.value!!
+                .assertVisibleItems(count = 8, startIndex = 2, cells = 2)
+        }
+    }
+
+    @Test
+    fun visibleItemsAreObservableWhenResize() {
+        lateinit var state: TvLazyGridState
+        var size by mutableStateOf(itemSizeDp * 2)
+        var currentInfo: TvLazyGridLayoutInfo? = null
+        @Composable
+        fun observingFun() {
+            currentInfo = state.layoutInfo
+        }
+        rule.setContent {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.crossAxisSize(itemSizeDp),
+                reverseLayout = reverseLayout,
+                state = rememberLazyGridState().also { state = it },
+            ) {
+                item {
+                    Box(Modifier.size(size))
+                }
+            }
+            observingFun()
+        }
+
+        rule.runOnIdle {
+            assertThat(currentInfo).isNotNull()
+            currentInfo!!.assertVisibleItems(
+                count = 1,
+                expectedSize = if (isVertical) {
+                    IntSize(itemSizePx, itemSizePx * 2)
+                } else {
+                    IntSize(itemSizePx * 2, itemSizePx)
+               },
+                cells = 1
+            )
+            currentInfo = null
+            size = itemSizeDp
+        }
+
+        rule.runOnIdle {
+            assertThat(currentInfo).isNotNull()
+            currentInfo!!.assertVisibleItems(
+                count = 1,
+                expectedSize = IntSize(itemSizePx, itemSizePx),
+                cells = 1
+            )
+        }
+    }
+
+    @Test
+    fun totalCountIsCorrect() {
+        var count by mutableStateOf(10)
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                reverseLayout = reverseLayout,
+                state = rememberLazyGridState().also { state = it },
+            ) {
+                items((0 until count).toList()) {
+                    Box(Modifier.mainAxisSize(10.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.totalItemsCount).isEqualTo(10)
+            count = 20
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.totalItemsCount).isEqualTo(20)
+        }
+    }
+
+    @Test
+    fun viewportOffsetsAndSizeAreCorrect() {
+        val sizePx = 45
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.axisSize(sizeDp * 2, sizeDp),
+                reverseLayout = reverseLayout,
+                state = rememberLazyGridState().also { state = it },
+            ) {
+                items((0..7).toList()) {
+                    Box(Modifier.mainAxisSize(sizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(0)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (isVertical) {
+                    IntSize(sizePx * 2, sizePx)
+                } else {
+                    IntSize(sizePx, sizePx * 2)
+                }
+            )
+        }
+    }
+
+    @Test
+    fun viewportOffsetsAndSizeAreCorrectWithContentPadding() {
+        val sizePx = 45
+        val startPaddingPx = 10
+        val endPaddingPx = 15
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        val beforeContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) startPaddingPx.toDp() else endPaddingPx.toDp()
+        }
+        val afterContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) endPaddingPx.toDp() else startPaddingPx.toDp()
+        }
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                modifier = Modifier.axisSize(sizeDp * 2, sizeDp),
+                contentPadding = PaddingValues(
+                    beforeContent = beforeContentPaddingDp,
+                    afterContent = afterContentPaddingDp,
+                    beforeContentCrossAxis = 2.dp,
+                    afterContentCrossAxis = 2.dp
+                ),
+                reverseLayout = reverseLayout,
+                state = rememberLazyGridState().also { state = it },
+            ) {
+                items((0..7).toList()) {
+                    Box(Modifier.mainAxisSize(sizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(-startPaddingPx)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx - startPaddingPx)
+            assertThat(state.layoutInfo.afterContentPadding).isEqualTo(endPaddingPx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (isVertical) {
+                    IntSize(sizePx * 2, sizePx)
+                } else {
+                    IntSize(sizePx, sizePx * 2)
+                }
+            )
+        }
+    }
+
+    @Test
+    fun emptyItemsInVisibleItemsInfo() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                state = rememberLazyGridState().also { state = it }
+            ) {
+                item { Box(Modifier) }
+                item { }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.visibleItemsInfo.size).isEqualTo(2)
+            assertThat(state.layoutInfo.visibleItemsInfo.first().index).isEqualTo(0)
+            assertThat(state.layoutInfo.visibleItemsInfo.last().index).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun emptyContent() {
+        lateinit var state: TvLazyGridState
+        val sizePx = 45
+        val startPaddingPx = 10
+        val endPaddingPx = 15
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        val beforeContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) startPaddingPx.toDp() else endPaddingPx.toDp()
+        }
+        val afterContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) endPaddingPx.toDp() else startPaddingPx.toDp()
+        }
+        rule.setContent {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.mainAxisSize(sizeDp).crossAxisSize(sizeDp * 2),
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(
+                    beforeContent = beforeContentPaddingDp,
+                    afterContent = afterContentPaddingDp
+                )
+            ) {
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(-startPaddingPx)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx - startPaddingPx)
+            assertThat(state.layoutInfo.beforeContentPadding).isEqualTo(startPaddingPx)
+            assertThat(state.layoutInfo.afterContentPadding).isEqualTo(endPaddingPx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (vertical) IntSize(sizePx * 2, sizePx) else IntSize(sizePx, sizePx * 2)
+            )
+        }
+    }
+
+    @Test
+    fun viewportIsLargerThenTheContent() {
+        lateinit var state: TvLazyGridState
+        val sizePx = 45
+        val startPaddingPx = 10
+        val endPaddingPx = 15
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        val beforeContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) startPaddingPx.toDp() else endPaddingPx.toDp()
+        }
+        val afterContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) endPaddingPx.toDp() else startPaddingPx.toDp()
+        }
+        rule.setContent {
+            LazyGrid(
+                cells = 1,
+                modifier = Modifier.mainAxisSize(sizeDp).crossAxisSize(sizeDp * 2),
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(
+                    beforeContent = beforeContentPaddingDp,
+                    afterContent = afterContentPaddingDp
+                )
+            ) {
+                item {
+                    Box(Modifier.size(sizeDp / 2))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(-startPaddingPx)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx - startPaddingPx)
+            assertThat(state.layoutInfo.beforeContentPadding).isEqualTo(startPaddingPx)
+            assertThat(state.layoutInfo.afterContentPadding).isEqualTo(endPaddingPx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (vertical) IntSize(sizePx * 2, sizePx) else IntSize(sizePx, sizePx * 2)
+            )
+        }
+    }
+
+    @Test
+    fun reverseLayoutIsCorrect() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.width(gridWidthDp).height(itemSizeDp * 3.5f),
+            ) {
+                items((0..11).toList()) {
+                    Box(Modifier.size(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.reverseLayout).isEqualTo(reverseLayout)
+        }
+    }
+
+    @Test
+    fun orientationIsCorrect() {
+        lateinit var state: TvLazyGridState
+        rule.setContent {
+            LazyGrid(
+                cells = 2,
+                state = rememberLazyGridState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.axisSize(gridWidthDp, itemSizeDp * 3.5f),
+            ) {
+                items((0..11).toList()) {
+                    Box(Modifier.mainAxisSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.orientation == Orientation.Vertical).isEqualTo(isVertical)
+        }
+    }
+
+    fun TvLazyGridLayoutInfo.assertVisibleItems(
+        count: Int,
+        cells: Int,
+        startIndex: Int = 0,
+        startOffset: Int = 0,
+        expectedSize: IntSize = IntSize(itemSizePx, itemSizePx),
+        spacing: Int = 0
+    ) {
+        assertThat(visibleItemsInfo.size).isEqualTo(count)
+        if (count == 0) return
+
+        assertThat(startIndex % cells).isEqualTo(0)
+        assertThat(visibleItemsInfo.size % cells).isEqualTo(0)
+
+        var currentIndex = startIndex
+        var currentOffset = startOffset
+        var currentLine = startIndex / cells
+        var currentCell = 0
+        visibleItemsInfo.forEach {
+            assertThat(it.index).isEqualTo(currentIndex)
+            assertWithMessage("Offset of item $currentIndex")
+                .that(if (isVertical) it.offset.y else it.offset.x)
+                .isEqualTo(currentOffset)
+            assertThat(it.size).isEqualTo(expectedSize)
+            assertThat(if (isVertical) it.row else it.column)
+                .isEqualTo(currentLine)
+            assertThat(if (isVertical) it.column else it.row)
+                .isEqualTo(currentCell)
+            currentIndex++
+            currentCell++
+            if (currentCell == cells) {
+                currentCell = 0
+                ++currentLine
+                currentOffset += spacing + if (isVertical) it.size.height else it.size.width
+            }
+        }
+    }
+}
+
+class LayoutInfoTestParam(
+    val orientation: Orientation,
+    val reverseLayout: Boolean
+) {
+    override fun toString(): String {
+        return "orientation=$orientation;reverseLayout=$reverseLayout"
+    }
+}
+
+@Stable
+class StableRef<T>(var value: T)
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt
new file mode 100644
index 0000000..9ab4802
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.animation.core.snap
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.testutils.assertIsEqualTo
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.tv.foundation.lazy.grid.keyPress
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+
+open class BaseLazyListTestWithOrientation(private val orientation: Orientation) {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    val vertical: Boolean
+        get() = orientation == Orientation.Vertical
+
+    fun Modifier.mainAxisSize(size: Dp) =
+        if (vertical) {
+            this.height(size)
+        } else {
+            this.width(size)
+        }
+
+    fun Modifier.crossAxisSize(size: Dp) =
+        if (vertical) {
+            this.width(size)
+        } else {
+            this.height(size)
+        }
+
+    fun Modifier.fillMaxCrossAxis() =
+        if (vertical) {
+            this.fillMaxWidth()
+        } else {
+            this.fillMaxHeight()
+        }
+
+    fun LazyItemScope.fillParentMaxCrossAxis() =
+        if (vertical) {
+            Modifier.fillParentMaxWidth()
+        } else {
+            Modifier.fillParentMaxHeight()
+        }
+
+    fun SemanticsNodeInteraction.assertMainAxisSizeIsEqualTo(expectedSize: Dp) =
+        if (vertical) {
+            assertHeightIsEqualTo(expectedSize)
+        } else {
+            assertWidthIsEqualTo(expectedSize)
+        }
+
+    fun SemanticsNodeInteraction.assertCrossAxisSizeIsEqualTo(expectedSize: Dp) =
+        if (vertical) {
+            assertWidthIsEqualTo(expectedSize)
+        } else {
+            assertHeightIsEqualTo(expectedSize)
+        }
+
+    fun SemanticsNodeInteraction.assertStartPositionIsAlmost(expected: Dp) {
+        val position = if (vertical) {
+            getUnclippedBoundsInRoot().top
+        } else {
+            getUnclippedBoundsInRoot().left
+        }
+        position.assertIsEqualTo(expected, tolerance = 1.dp)
+    }
+
+    fun SemanticsNodeInteraction.assertStartPositionInRootIsEqualTo(expectedStart: Dp) =
+        if (vertical) {
+            assertTopPositionInRootIsEqualTo(expectedStart)
+        } else {
+            assertLeftPositionInRootIsEqualTo(expectedStart)
+        }
+
+    fun SemanticsNodeInteraction.assertCrossAxisStartPositionInRootIsEqualTo(expectedStart: Dp) =
+        if (vertical) {
+            assertLeftPositionInRootIsEqualTo(expectedStart)
+        } else {
+            assertTopPositionInRootIsEqualTo(expectedStart)
+        }
+
+    fun PaddingValues(
+        mainAxis: Dp = 0.dp,
+        crossAxis: Dp = 0.dp
+    ) = PaddingValues(
+        beforeContent = mainAxis,
+        afterContent = mainAxis,
+        beforeContentCrossAxis = crossAxis,
+        afterContentCrossAxis = crossAxis
+    )
+
+    fun PaddingValues(
+        beforeContent: Dp = 0.dp,
+        afterContent: Dp = 0.dp,
+        beforeContentCrossAxis: Dp = 0.dp,
+        afterContentCrossAxis: Dp = 0.dp,
+    ) = if (vertical) {
+        PaddingValues(
+            start = beforeContentCrossAxis,
+            top = beforeContent,
+            end = afterContentCrossAxis,
+            bottom = afterContent
+        )
+    } else {
+        PaddingValues(
+            start = beforeContent,
+            top = beforeContentCrossAxis,
+            end = afterContent,
+            bottom = afterContentCrossAxis
+        )
+    }
+
+    fun TvLazyListState.scrollBy(offset: Dp) {
+        runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+            animateScrollBy(with(rule.density) { offset.roundToPx().toFloat() }, snap())
+        }
+    }
+
+    fun TvLazyListState.scrollTo(index: Int) {
+        runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+            scrollToItem(index)
+        }
+    }
+
+    fun ComposeContentTestRule.keyPress(numberOfKeyPresses: Int, reverseScroll: Boolean = false) {
+        val keyCode: Int =
+            when {
+                vertical && reverseScroll -> NativeKeyEvent.KEYCODE_DPAD_UP
+                vertical && !reverseScroll -> NativeKeyEvent.KEYCODE_DPAD_DOWN
+                !vertical && reverseScroll -> NativeKeyEvent.KEYCODE_DPAD_LEFT
+                !vertical && !reverseScroll -> NativeKeyEvent.KEYCODE_DPAD_RIGHT
+                else -> NativeKeyEvent.KEYCODE_DPAD_RIGHT
+            }
+
+        keyPress(keyCode, numberOfKeyPresses)
+    }
+
+    @Composable
+    fun LazyColumnOrRow(
+        modifier: Modifier = Modifier,
+        state: TvLazyListState = rememberLazyListState(),
+        contentPadding: PaddingValues = PaddingValues(0.dp),
+        reverseLayout: Boolean = false,
+        userScrollEnabled: Boolean = true,
+        spacedBy: Dp = 0.dp,
+        pivotOffsets: PivotOffsets =
+            PivotOffsets(parentFraction = 0f),
+        content: TvLazyListScope.() -> Unit
+    ) {
+        if (vertical) {
+            val verticalArrangement = when {
+                spacedBy != 0.dp -> Arrangement.spacedBy(spacedBy)
+                !reverseLayout -> Arrangement.Top
+                else -> Arrangement.Bottom
+            }
+            TvLazyColumn(
+                modifier = modifier,
+                state = state,
+                contentPadding = contentPadding,
+                reverseLayout = reverseLayout,
+                userScrollEnabled = userScrollEnabled,
+                verticalArrangement = verticalArrangement,
+                pivotOffsets = pivotOffsets,
+                content = content
+            )
+        } else {
+            val horizontalArrangement = when {
+                spacedBy != 0.dp -> Arrangement.spacedBy(spacedBy)
+                !reverseLayout -> Arrangement.Start
+                else -> Arrangement.End
+            }
+            TvLazyRow(
+                modifier = modifier,
+                state = state,
+                contentPadding = contentPadding,
+                reverseLayout = reverseLayout,
+                userScrollEnabled = userScrollEnabled,
+                horizontalArrangement = horizontalArrangement,
+                pivotOffsets = pivotOffsets,
+                content = content
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyArrangementsTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyArrangementsTest.kt
new file mode 100644
index 0000000..f960ffa
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyArrangementsTest.kt
@@ -0,0 +1,612 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.grid.keyPress
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class LazyArrangementsTest {
+
+    private val ContainerTag = "ContainerTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var itemSize: Dp = Dp.Infinity
+    private var smallerItemSize: Dp = Dp.Infinity
+    private var containerSize: Dp = Dp.Infinity
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSize = 50.toDp()
+        }
+        with(rule.density) {
+            smallerItemSize = 40.toDp()
+        }
+        containerSize = itemSize * 5
+    }
+
+    // cases when we have not enough items to fill min constraints:
+
+    @Test
+    fun column_defaultArrangementIsTop() {
+        rule.setContent {
+            TvLazyColumn(
+                modifier = Modifier.requiredSize(containerSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Top)
+    }
+
+    @Test
+    fun column_centerArrangement() {
+        composeColumnWith(Arrangement.Center)
+        assertArrangementForTwoItems(Arrangement.Center)
+    }
+
+    @Test
+    fun column_bottomArrangement() {
+        composeColumnWith(Arrangement.Bottom)
+        assertArrangementForTwoItems(Arrangement.Bottom)
+    }
+
+    @Test
+    fun column_spacedArrangementNotFillingViewport() {
+        val arrangement = Arrangement.spacedBy(10.dp)
+        composeColumnWith(arrangement)
+        assertArrangementForTwoItems(arrangement)
+    }
+
+    @Test
+    fun row_defaultArrangementIsStart() {
+        rule.setContent {
+            TvLazyRow(
+                modifier = Modifier.requiredSize(containerSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Start, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun row_centerArrangement() {
+        composeRowWith(Arrangement.Center, LayoutDirection.Ltr)
+        assertArrangementForTwoItems(Arrangement.Center, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun row_endArrangement() {
+        composeRowWith(Arrangement.End, LayoutDirection.Ltr)
+        assertArrangementForTwoItems(Arrangement.End, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun row_spacedArrangementNotFillingViewport() {
+        val arrangement = Arrangement.spacedBy(10.dp)
+        composeRowWith(arrangement, LayoutDirection.Ltr)
+        assertArrangementForTwoItems(arrangement, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun row_rtl_startArrangement() {
+        composeRowWith(Arrangement.Center, LayoutDirection.Rtl)
+        assertArrangementForTwoItems(Arrangement.Center, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun row_rtl_endArrangement() {
+        composeRowWith(Arrangement.End, LayoutDirection.Rtl)
+        assertArrangementForTwoItems(Arrangement.End, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun row_rtl_spacedArrangementNotFillingViewport() {
+        val arrangement = Arrangement.spacedBy(10.dp)
+        composeRowWith(arrangement, LayoutDirection.Rtl)
+        assertArrangementForTwoItems(arrangement, LayoutDirection.Rtl)
+    }
+
+    // wrap content and spacing
+
+    @Test
+    fun column_spacing_affects_wrap_content() {
+        rule.setContent {
+            TvLazyColumn(
+                verticalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize).focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertWidthIsEqualTo(itemSize)
+            .assertHeightIsEqualTo(itemSize * 3)
+    }
+
+    @Test
+    fun row_spacing_affects_wrap_content() {
+        rule.setContent {
+            TvLazyRow(
+                horizontalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Box(Modifier.requiredSize(itemSize).focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertWidthIsEqualTo(itemSize * 3)
+            .assertHeightIsEqualTo(itemSize)
+    }
+
+    // spacing added when we have enough items to fill the viewport
+
+    @Test
+    fun column_spacing_scrolledToTheTop() {
+        rule.setContent {
+            TvLazyColumn(
+                verticalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2)
+    }
+
+    @Test
+    fun column_spacing_scrolledToTheBottom() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                verticalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 3)
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize * 0.5f)
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize * 2.5f)
+    }
+
+    @Test
+    fun row_spacing_scrolledToTheStart() {
+        rule.setContent {
+            TvLazyRow(
+                horizontalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 2)
+    }
+
+    @Test
+    fun row_spacing_scrolledToTheEnd() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                horizontalArrangement = Arrangement.spacedBy(itemSize),
+                modifier = Modifier.requiredSize(itemSize * 3.5f).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(3) {
+                    Box(Modifier.requiredSize(itemSize).testTag(it.toString()).focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 3)
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 0.5f)
+
+        rule.onNodeWithTag("2")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 2.5f)
+    }
+
+    @Test
+    fun column_scrollingByExactlyTheItemSizePlusSpacer_switchesTheFirstVisibleItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                Modifier.size(itemSize * 3),
+                state = rememberLazyListState().also { state = it },
+                verticalArrangement = Arrangement.spacedBy(spacingSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it").focusable()
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun column_scrollingByExactlyTheItemSizePlusHalfTheSpacer_staysOnTheSameItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                Modifier.size(itemSize * 3),
+                state = rememberLazyListState().also { state = it },
+                verticalArrangement = Arrangement.spacedBy(spacingSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it").focusable()
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx / 2).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            assertThat(state.firstVisibleItemScrollOffset)
+                .isEqualTo(itemSizePx + spacingSizePx / 2)
+        }
+    }
+
+    @Test
+    fun row_scrollingByExactlyTheItemSizePlusSpacer_switchesTheFirstVisibleItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                Modifier.size(itemSize * 3),
+                state = rememberLazyListState().also { state = it },
+                horizontalArrangement = Arrangement.spacedBy(spacingSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(5) {
+                    Box(
+                        Modifier.size(itemSize).testTag("$it").focusable()
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun row_scrollingByExactlyTheItemSizePlusHalfTheSpacer_staysOnTheSameItem() {
+        val itemSizePx = 30
+        val spacingSizePx = 4
+        val itemSize = with(rule.density) { itemSizePx.toDp() }
+        val spacingSize = with(rule.density) { spacingSizePx.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                Modifier.size(itemSize * 3),
+                state = rememberLazyListState().also { state = it },
+                horizontalArrangement = Arrangement.spacedBy(spacingSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(5) {
+                    Box(
+                        Modifier.size(itemSize).testTag("$it").focusable()
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy((itemSizePx + spacingSizePx / 2).toFloat())
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            assertThat(state.firstVisibleItemScrollOffset)
+                .isEqualTo(itemSizePx + spacingSizePx / 2)
+        }
+    }
+
+    // with reverseLayout == true
+
+    @Test
+    fun column_defaultArrangementIsBottomWithReverseLayout() {
+        rule.setContent {
+            TvLazyColumn(
+                reverseLayout = true,
+                modifier = Modifier.requiredSize(containerSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Bottom, reverseLayout = true)
+    }
+
+    @Test
+    fun row_defaultArrangementIsEndWithReverseLayout() {
+        rule.setContent {
+            TvLazyRow(
+                reverseLayout = true,
+                modifier = Modifier.requiredSize(containerSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(
+            Arrangement.End, LayoutDirection.Ltr, reverseLayout = true
+        )
+    }
+
+    @Test
+    fun column_whenArrangementChanges() {
+        var arrangement by mutableStateOf(Arrangement.Top)
+        rule.setContent {
+            TvLazyColumn(
+                modifier = Modifier.requiredSize(containerSize),
+                verticalArrangement = arrangement,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Top)
+
+        rule.runOnIdle {
+            arrangement = Arrangement.Bottom
+        }
+
+        assertArrangementForTwoItems(Arrangement.Bottom)
+    }
+
+    @Test
+    fun row_whenArrangementChanges() {
+        var arrangement by mutableStateOf(Arrangement.Start)
+        rule.setContent {
+            TvLazyRow(
+                modifier = Modifier.requiredSize(containerSize),
+                horizontalArrangement = arrangement,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertArrangementForTwoItems(Arrangement.Start, LayoutDirection.Ltr)
+
+        rule.runOnIdle {
+            arrangement = Arrangement.End
+        }
+
+        assertArrangementForTwoItems(Arrangement.End, LayoutDirection.Ltr)
+    }
+
+    fun composeColumnWith(arrangement: Arrangement.Vertical) {
+        rule.setContent {
+            TvLazyColumn(
+                verticalArrangement = arrangement,
+                modifier = Modifier.requiredSize(containerSize),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    Item(it)
+                }
+            }
+        }
+    }
+
+    fun composeRowWith(arrangement: Arrangement.Horizontal, layoutDirection: LayoutDirection) {
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
+                TvLazyRow(
+                    horizontalArrangement = arrangement,
+                    modifier = Modifier.requiredSize(containerSize),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(2) {
+                        Item(it)
+                    }
+                }
+            }
+        }
+    }
+
+    @Composable
+    fun Item(index: Int) {
+        require(index < 2)
+        val size = if (index == 0) itemSize else smallerItemSize
+        Box(Modifier.requiredSize(size).testTag(index.toString()))
+    }
+
+    fun assertArrangementForTwoItems(
+        arrangement: Arrangement.Vertical,
+        reverseLayout: Boolean = false
+    ) {
+        with(rule.density) {
+            val sizes = IntArray(2) {
+                val index = if (reverseLayout) if (it == 0) 1 else 0 else it
+                if (index == 0) itemSize.roundToPx() else smallerItemSize.roundToPx()
+            }
+            val outPositions = IntArray(2) { 0 }
+            with(arrangement) { arrange(containerSize.roundToPx(), sizes, outPositions) }
+
+            outPositions.forEachIndexed { index, position ->
+                val realIndex = if (reverseLayout) if (index == 0) 1 else 0 else index
+                rule.onNodeWithTag("$realIndex")
+                    .assertTopPositionInRootIsEqualTo(position.toDp())
+            }
+        }
+    }
+
+    fun assertArrangementForTwoItems(
+        arrangement: Arrangement.Horizontal,
+        layoutDirection: LayoutDirection,
+        reverseLayout: Boolean = false
+    ) {
+        with(rule.density) {
+            val sizes = IntArray(2) {
+                val index = if (reverseLayout) if (it == 0) 1 else 0 else it
+                if (index == 0) itemSize.roundToPx() else smallerItemSize.roundToPx()
+            }
+            val outPositions = IntArray(2) { 0 }
+            with(arrangement) {
+                arrange(containerSize.roundToPx(), sizes, layoutDirection, outPositions)
+            }
+
+            outPositions.forEachIndexed { index, position ->
+                val realIndex = if (reverseLayout) if (index == 0) 1 else 0 else index
+                val expectedPosition = position.toDp()
+                rule.onNodeWithTag("$realIndex")
+                    .assertLeftPositionInRootIsEqualTo(expectedPosition)
+            }
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyColumnTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyColumnTest.kt
new file mode 100644
index 0000000..8e8bc51
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyColumnTest.kt
@@ -0,0 +1,502 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import android.os.Build
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertPixels
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+/**
+ * This class contains all LazyColumn-specific tests, as well as (by convention) tests that don't
+ * need to be run in both orientations.
+ *
+ * To have a test run in both orientations (LazyRow and LazyColumn), add it to [LazyListTest]
+ */
+class LazyColumnTest {
+    private val LazyListTag = "LazyListTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun compositionsAreDisposed_whenDataIsChanged() {
+        var composed = 0
+        var disposals = 0
+        val data1 = (1..3).toList()
+        val data2 = (4..5).toList() // smaller, to ensure removal is handled properly
+
+        var part2 by mutableStateOf(false)
+
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                Modifier.testTag(LazyListTag).fillMaxSize(),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(if (!part2) data1 else data2) {
+                    DisposableEffect(NeverEqualObject) {
+                        composed++
+                        onDispose {
+                            disposals++
+                        }
+                    }
+
+                    Box(Modifier.height(50.dp).focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertWithMessage("Not all items were composed")
+                .that(composed).isEqualTo(data1.size)
+            composed = 0
+
+            part2 = true
+        }
+
+        rule.runOnIdle {
+            assertWithMessage(
+                "No additional items were composed after data change, something didn't work"
+            ).that(composed).isEqualTo(data2.size)
+
+            // We may need to modify this test once we prefetch/cache items outside the viewport
+            assertWithMessage(
+                "Not enough compositions were disposed after scrolling, compositions were leaked"
+            ).that(disposals).isEqualTo(data1.size)
+        }
+    }
+
+    @Test
+    fun compositionsAreDisposed_whenLazyListIsDisposed() {
+        var emitLazyList by mutableStateOf(true)
+        var disposeCalledOnFirstItem = false
+        var disposeCalledOnSecondItem = false
+
+        rule.setContentWithTestViewConfiguration {
+            if (emitLazyList) {
+                TvLazyColumn(
+                    Modifier.fillMaxSize(),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(2) {
+                        Box(Modifier.requiredSize(100.dp).focusable())
+                        DisposableEffect(Unit) {
+                            onDispose {
+                                if (it == 1) {
+                                    disposeCalledOnFirstItem = true
+                                } else {
+                                    disposeCalledOnSecondItem = true
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertWithMessage("First item was incorrectly immediately disposed")
+                .that(disposeCalledOnFirstItem).isFalse()
+            assertWithMessage("Second item was incorrectly immediately disposed")
+                .that(disposeCalledOnFirstItem).isFalse()
+            emitLazyList = false
+        }
+
+        rule.runOnIdle {
+            assertWithMessage("First item was not correctly disposed")
+                .that(disposeCalledOnFirstItem).isTrue()
+            assertWithMessage("Second item was not correctly disposed")
+                .that(disposeCalledOnSecondItem).isTrue()
+        }
+    }
+
+    @Test
+    fun removeItemsTest() {
+        val startingNumItems = 3
+        var numItems = startingNumItems
+        var numItemsModel by mutableStateOf(numItems)
+        val tag = "List"
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                Modifier.testTag(tag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((1..numItemsModel).toList()) {
+                    BasicText("$it")
+                }
+            }
+        }
+
+        while (numItems >= 0) {
+            // Confirm the number of children to ensure there are no extra items
+            rule.onNodeWithTag(tag)
+                .onChildren()
+                .assertCountEquals(numItems)
+
+            // Confirm the children's content
+            for (i in 1..3) {
+                rule.onNodeWithText("$i").apply {
+                    if (i <= numItems) {
+                        assertExists()
+                    } else {
+                        assertDoesNotExist()
+                    }
+                }
+            }
+            numItems--
+            if (numItems >= 0) {
+                // Don't set the model to -1
+                rule.runOnIdle { numItemsModel = numItems }
+            }
+        }
+    }
+
+    @Test
+    fun changeItemsCountAndScrollImmediately() {
+        lateinit var state: TvLazyListState
+        var count by mutableStateOf(100)
+        val composedIndexes = mutableListOf<Int>()
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.fillMaxWidth().height(10.dp),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(count) { index ->
+                    composedIndexes.add(index)
+                    Box(Modifier.size(20.dp).focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            composedIndexes.clear()
+            count = 10
+            runBlocking(AutoTestFrameClock()) {
+                state.scrollToItem(50)
+            }
+            composedIndexes.forEach {
+                assertThat(it).isLessThan(count)
+            }
+            assertThat(state.firstVisibleItemIndex).isEqualTo(9)
+        }
+    }
+
+    @Test
+    fun changingDataTest() {
+        val dataLists = listOf(
+            (1..3).toList(),
+            (4..8).toList(),
+            (3..4).toList()
+        )
+        var dataModel by mutableStateOf(dataLists[0])
+        val tag = "List"
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                Modifier.testTag(tag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(dataModel) {
+                    BasicText("$it")
+                }
+            }
+        }
+
+        for (data in dataLists) {
+            rule.runOnIdle { dataModel = data }
+
+            // Confirm the number of children to ensure there are no extra items
+            val numItems = data.size
+            rule.onNodeWithTag(tag)
+                .onChildren()
+                .assertCountEquals(numItems)
+
+            // Confirm the children's content
+            for (item in data) {
+                rule.onNodeWithText("$item").assertExists()
+            }
+        }
+    }
+
+    private val firstItemTag = "firstItemTag"
+    private val secondItemTag = "secondItemTag"
+
+    private fun prepareLazyColumnsItemsAlignment(horizontalGravity: Alignment.Horizontal) {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                Modifier.testTag(LazyListTag).requiredWidth(100.dp),
+                horizontalAlignment = horizontalGravity,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(listOf(1, 2)) {
+                    if (it == 1) {
+                        Box(Modifier.size(50.dp).testTag(firstItemTag).focusable())
+                    } else {
+                        Box(Modifier.size(70.dp).testTag(secondItemTag).focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertIsDisplayed()
+
+        val lazyColumnBounds = rule.onNodeWithTag(LazyListTag)
+            .getUnclippedBoundsInRoot()
+
+        with(rule.density) {
+            // Verify the width of the column
+            assertThat(lazyColumnBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+            assertThat(lazyColumnBounds.right.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
+        }
+    }
+
+    @Test
+    fun lazyColumnAlignmentCenterHorizontally() {
+        prepareLazyColumnsItemsAlignment(Alignment.CenterHorizontally)
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertPositionInRootIsEqualTo(25.dp, 0.dp)
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertPositionInRootIsEqualTo(15.dp, 50.dp)
+    }
+
+    @Test
+    fun lazyColumnAlignmentStart() {
+        prepareLazyColumnsItemsAlignment(Alignment.Start)
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertPositionInRootIsEqualTo(0.dp, 50.dp)
+    }
+
+    @Test
+    fun lazyColumnAlignmentEnd() {
+        prepareLazyColumnsItemsAlignment(Alignment.End)
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertPositionInRootIsEqualTo(50.dp, 0.dp)
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertPositionInRootIsEqualTo(30.dp, 50.dp)
+    }
+
+    @Test
+    fun removalWithMutableStateListOf() {
+        val items = mutableStateListOf("1", "2", "3")
+
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn {
+                items(items) { item ->
+                    Spacer(Modifier.size(itemSize).testTag(item))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            items.removeLast()
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun recompositionOrder() {
+        val outerState = mutableStateOf(0)
+        val innerState = mutableStateOf(0)
+        val recompositions = mutableListOf<Pair<Int, Int>>()
+
+        rule.setContent {
+            val localOuterState = outerState.value
+            TvLazyColumn {
+                items(count = 1) {
+                    recompositions.add(localOuterState to innerState.value)
+                    Box(Modifier.fillMaxSize())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            innerState.value++
+            outerState.value++
+        }
+
+        rule.runOnIdle {
+            assertThat(recompositions).isEqualTo(
+                listOf(0 to 0, 1 to 1)
+            )
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun scrolledAwayItemIsNotDisplayedAnymore() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier
+                    .requiredSize(10.dp)
+                    .testTag(LazyListTag)
+                    .graphicsLayer()
+                    .background(Color.Blue),
+                state = state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(2) {
+                    val size = if (it == 0) 5.dp else 100.dp
+                    val color = if (it == 0) Color.Red else Color.Transparent
+                    Box(
+                        Modifier
+                            .fillMaxWidth()
+                            .height(size)
+                            .background(color)
+                            .testTag("$it")
+                            .focusable()
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            with(rule.density) {
+                runBlocking {
+                    // we scroll enough to make the Red item not visible anymore
+                    state.scrollBy(6.dp.toPx())
+                }
+            }
+        }
+
+        // and verify there is no Red item displayed
+        rule.onNodeWithTag(LazyListTag)
+            .captureToImage()
+            .assertPixels {
+                Color.Blue
+            }
+    }
+
+    @Test
+    fun wrappedNestedLazyRowDisplayCorrectContent() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.size(20.dp),
+                state = state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    LazyRowWrapped {
+                        BasicText("$it", Modifier.size(21.dp))
+                    }
+                }
+            }
+        }
+
+        (1..10).forEach { item ->
+            rule.runOnIdle {
+                runBlocking {
+                    state.scrollToItem(item)
+                }
+            }
+
+            rule.onNodeWithText("$item")
+                .assertIsDisplayed()
+        }
+    }
+
+    @Composable
+    private fun LazyRowWrapped(content: @Composable () -> Unit) {
+        TvLazyRow {
+            items(count = 1) {
+                content()
+            }
+        }
+    }
+}
+
+internal fun Modifier.drawOutsideOfBounds() = drawBehind {
+    val inflate = 20.dp.roundToPx().toFloat()
+    drawRect(
+        Color.Red,
+        Offset(-inflate, -inflate),
+        Size(size.width + inflate * 2, size.height + inflate * 2)
+    )
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyCustomKeysTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyCustomKeysTest.kt
new file mode 100644
index 0000000..69d0123
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyCustomKeysTest.kt
@@ -0,0 +1,491 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalFoundationApi::class)
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazyCustomKeysTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    val itemSize = with(rule.density) {
+        100.toDp()
+    }
+
+    @Test
+    fun itemsWithKeysAreLaidOutCorrectly() {
+        val list = listOf(MyClass(0), MyClass(1), MyClass(2))
+
+        rule.setContent {
+            TvLazyColumn {
+                items(list, key = { it.id }) {
+                    Item("${it.id}")
+                }
+            }
+        }
+
+        assertItems("0", "1", "2")
+    }
+
+    @Test
+    fun removing_statesAreMoved() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+
+        rule.setContent {
+            TvLazyColumn {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(list[0], list[2])
+        }
+
+        assertItems("0", "2")
+    }
+
+    @Test
+    fun reordering_statesAreMoved_list() {
+        testReordering { list ->
+            items(list, key = { it.id }) {
+                Item(remember { "${it.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_list_indexed() {
+        testReordering { list ->
+            itemsIndexed(list, key = { _, item -> item.id }) { _, item ->
+                Item(remember { "${item.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_array() {
+        testReordering { list ->
+            val array = list.toTypedArray()
+            items(array, key = { it.id }) {
+                Item(remember { "${it.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_array_indexed() {
+        testReordering { list ->
+            val array = list.toTypedArray()
+            itemsIndexed(array, key = { _, item -> item.id }) { _, item ->
+                Item(remember { "${item.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun reordering_statesAreMoved_itemsWithCount() {
+        testReordering { list ->
+            items(list.size, key = { list[it].id }) {
+                Item(remember { "${list[it].id}" })
+            }
+        }
+    }
+
+    @Test
+    fun fullyReplacingTheList() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        var counter = 0
+
+        rule.setContent {
+            TvLazyColumn {
+                items(list, key = { it.id }) {
+                    Item(remember { counter++ }.toString())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(MyClass(3), MyClass(4), MyClass(5), MyClass(6))
+        }
+
+        assertItems("3", "4", "5", "6")
+    }
+
+    @Test
+    fun keepingOneItem() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        var counter = 0
+
+        rule.setContent {
+            TvLazyColumn {
+                items(list, key = { it.id }) {
+                    Item(remember { counter++ }.toString())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(MyClass(1))
+        }
+
+        assertItems("1")
+    }
+
+    @Test
+    fun keepingOneItemAndAddingMore() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        var counter = 0
+
+        rule.setContent {
+            TvLazyColumn {
+                items(list, key = { it.id }) {
+                    Item(remember { counter++ }.toString())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(MyClass(1), MyClass(3))
+        }
+
+        assertItems("1", "3")
+    }
+
+    @Test
+    fun mixingKeyedItemsAndNot() {
+        testReordering { list ->
+            item {
+                Item("${list.first().id}")
+            }
+            items(list.subList(fromIndex = 1, toIndex = list.size), key = { it.id }) {
+                Item(remember { "${it.id}" })
+            }
+        }
+    }
+
+    @Test
+    fun updatingTheDataSetIsCorrectlyApplied() {
+        val state = mutableStateOf(emptyList<Int>())
+
+        rule.setContent {
+            LaunchedEffect(Unit) {
+                state.value = listOf(4, 1, 3)
+            }
+
+            val list = state.value
+
+            TvLazyColumn(
+                Modifier.fillMaxSize(),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list, key = { it }) {
+                    Item(it.toString())
+                }
+            }
+        }
+
+        assertItems("4", "1", "3")
+
+        rule.runOnIdle {
+            state.value = listOf(2, 4, 6, 1, 3, 5)
+        }
+
+        assertItems("2", "4", "6", "1", "3", "5")
+    }
+
+    @Test
+    fun reordering_usingMutableStateListOf() {
+        val list = mutableStateListOf(MyClass(0), MyClass(1), MyClass(2))
+
+        rule.setContent {
+            TvLazyColumn {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list.add(list.removeAt(1))
+        }
+
+        assertItems("0", "2", "1")
+    }
+
+    @Test
+    fun keysInLazyListItemInfoAreCorrect() {
+        val list = listOf(MyClass(0), MyClass(1), MyClass(2))
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                state = state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)) {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(0, 1, 2))
+        }
+    }
+
+    @Test
+    fun keysInLazyListItemInfoAreCorrectAfterReordering() {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                state = state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list, key = { it.id }) {
+                    Item(remember { "${it.id}" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(list[0], list[2], list[1])
+        }
+
+        rule.runOnIdle {
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(0, 2, 1))
+        }
+    }
+
+    @Test
+    fun addingItemsBeforeWithoutKeysIsMaintainingTheIndex() {
+        var list by mutableStateOf((10..15).toList())
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.size(itemSize * 2.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..15).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun addingItemsBeforeKeepingThisItemFirst() {
+        var list by mutableStateOf((10..15).toList())
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.size(itemSize * 2.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..15).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(10)
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(10, 11, 12))
+        }
+    }
+
+    @Test
+    fun addingItemsRightAfterKeepingThisItemFirst() {
+        var list by mutableStateOf((0..5).toList() + (10..15).toList())
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState(5)
+            TvLazyColumn(
+                Modifier.size(itemSize * 2.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..15).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(5)
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(5, 6, 7))
+        }
+    }
+
+    @Test
+    fun addingItemsBeforeWhileCurrentItemIsNotInTheBeginning() {
+        var list by mutableStateOf((10..30).toList())
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState(10) // key 20 is the first item
+            TvLazyColumn(
+                Modifier.size(itemSize * 2.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..30).toList()
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(20)
+            assertThat(
+                state.visibleKeys
+            ).isEqualTo(listOf(20, 21, 22))
+        }
+    }
+
+    @Test
+    fun removingTheCurrentItemMaintainsTheIndex() {
+        var list by mutableStateOf((0..20).toList())
+        lateinit var state: TvLazyListState
+
+        rule.setContent {
+            state = rememberLazyListState(5)
+            TvLazyColumn(
+                Modifier.size(itemSize * 2.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(list, key = { it }) {
+                    Item(remember { "$it" })
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = (0..20) - 5
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(5)
+            assertThat(state.visibleKeys).isEqualTo(listOf(6, 7, 8))
+        }
+    }
+
+    private fun testReordering(content: TvLazyListScope.(List<MyClass>) -> Unit) {
+        var list by mutableStateOf(listOf(MyClass(0), MyClass(1), MyClass(2)))
+
+        rule.setContent {
+            TvLazyColumn {
+                content(list)
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(list[0], list[2], list[1])
+        }
+
+        assertItems("0", "2", "1")
+    }
+
+    private fun assertItems(vararg tags: String) {
+        var currentTop = 0.dp
+        tags.forEach {
+            rule.onNodeWithTag(it)
+                .assertTopPositionInRootIsEqualTo(currentTop)
+                .assertHeightIsEqualTo(itemSize)
+            currentTop += itemSize
+        }
+    }
+
+    @Composable
+    private fun Item(tag: String) {
+        Spacer(
+            Modifier.testTag(tag).size(itemSize)
+        )
+    }
+
+    private class MyClass(val id: Int)
+}
+
+val TvLazyListState.visibleKeys: List<Any> get() = layoutInfo.visibleItemsInfo.map { it.key }
\ No newline at end of file
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyItemStateRestoration.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyItemStateRestoration.kt
new file mode 100644
index 0000000..6a037fb
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyItemStateRestoration.kt
@@ -0,0 +1,320 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.StateRestorationTester
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+
+class LazyItemStateRestoration {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun visibleItemsStateRestored() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        var counter2 = 100
+        var realState = arrayOf(0, 0, 0)
+        restorationTester.setContent {
+            TvLazyColumn {
+                item {
+                    realState[0] = rememberSaveable { counter0++ }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+                items((1..2).toList()) {
+                    if (it == 1) {
+                        realState[1] = rememberSaveable { counter1++ }
+                    } else {
+                        realState[2] = rememberSaveable { counter2++ }
+                    }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+            realState = arrayOf(0, 0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+        }
+    }
+
+    @Test
+    fun itemsStateRestoredWhenWeScrolledBackToIt() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        lateinit var state: TvLazyListState
+        var itemDisposed = false
+        var realState = 0
+        restorationTester.setContent {
+            TvLazyColumn(
+                Modifier.requiredSize(20.dp),
+                state = rememberLazyListState().also { state = it },
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..10).toList()) {
+                    if (it == 0) {
+                        realState = rememberSaveable { counter0++ }
+                        DisposableEffect(Unit) {
+                            onDispose {
+                                itemDisposed = true
+                            }
+                        }
+                    }
+                    Box(Modifier.requiredSize(30.dp).focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+            runBlocking {
+                // we scroll through multiple items to make sure the 0th element is not kept in
+                // the reusable items buffer
+                state.scrollToItem(3)
+                state.scrollToItem(5)
+                state.scrollToItem(8)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(itemDisposed).isEqualTo(true)
+            realState = 0
+            runBlocking {
+                state.scrollToItem(0, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun itemsStateRestoredWhenWeScrolledRestoredAndScrolledBackTo() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        lateinit var state: TvLazyListState
+        var realState = arrayOf(0, 0)
+        restorationTester.setContent {
+            TvLazyColumn(
+                Modifier.requiredSize(20.dp),
+                state = rememberLazyListState().also { state = it },
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..1).toList()) {
+                    if (it == 0) {
+                        realState[0] = rememberSaveable { counter0++ }
+                    } else {
+                        realState[1] = rememberSaveable { counter1++ }
+                    }
+                    Box(Modifier.requiredSize(30.dp).focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            runBlocking {
+                state.scrollToItem(1, 5)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[1]).isEqualTo(10)
+            realState = arrayOf(0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[1]).isEqualTo(10)
+            runBlocking {
+                state.scrollToItem(0, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun nestedLazy_itemsStateRestoredWhenWeScrolledBackToIt() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        lateinit var state: TvLazyListState
+        var itemDisposed = false
+        var realState = 0
+        restorationTester.setContent {
+            TvLazyColumn(
+                Modifier.requiredSize(20.dp),
+                state = rememberLazyListState().also { state = it },
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..10).toList()) {
+                    if (it == 0) {
+                        TvLazyRow {
+                            item {
+                                realState = rememberSaveable { counter0++ }
+                                DisposableEffect(Unit) {
+                                    onDispose {
+                                        itemDisposed = true
+                                    }
+                                }
+                                Box(Modifier.requiredSize(30.dp).focusable())
+                            }
+                        }
+                    } else {
+                        Box(Modifier.requiredSize(30.dp).focusable())
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+            runBlocking {
+                // we scroll through multiple items to make sure the 0th element is not kept in
+                // the reusable items buffer
+                state.scrollToItem(3)
+                state.scrollToItem(5)
+                state.scrollToItem(8)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(itemDisposed).isEqualTo(true)
+            realState = 0
+            runBlocking {
+                state.scrollToItem(0, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun stateRestoredWhenUsedWithCustomKeys() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        var counter2 = 100
+        var realState = arrayOf(0, 0, 0)
+        restorationTester.setContent {
+            TvLazyColumn {
+                items(3, key = { "$it" }) {
+                    if (it == 0) {
+                        realState[0] = rememberSaveable { counter0++ }
+                    } else if (it == 1) {
+                        realState[1] = rememberSaveable { counter1++ }
+                    } else {
+                        realState[2] = rememberSaveable { counter2++ }
+                    }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+            realState = arrayOf(0, 0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+        }
+    }
+
+    @Test
+    fun stateRestoredWhenUsedWithCustomKeysAfterReordering() {
+        val restorationTester = StateRestorationTester(rule)
+        var counter0 = 1
+        var counter1 = 10
+        var counter2 = 100
+        var realState = arrayOf(0, 0, 0)
+        var list by mutableStateOf(listOf(0, 1, 2))
+        restorationTester.setContent {
+            TvLazyColumn {
+                items(list, key = { "$it" }) {
+                    if (it == 0) {
+                        realState[0] = rememberSaveable { counter0++ }
+                    } else if (it == 1) {
+                        realState[1] = rememberSaveable { counter1++ }
+                    } else {
+                        realState[2] = rememberSaveable { counter2++ }
+                    }
+                    Box(Modifier.requiredSize(1.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2)
+        }
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(1)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+            realState = arrayOf(0, 0, 0)
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(realState[0]).isEqualTo(0)
+            assertThat(realState[1]).isEqualTo(10)
+            assertThat(realState[2]).isEqualTo(100)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt
new file mode 100644
index 0000000..351bf6f
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt
@@ -0,0 +1,1226 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredHeightIn
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.requiredWidthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.width
+import androidx.test.filters.LargeTest
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.runBlocking
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import kotlin.math.roundToInt
+
+@LargeTest
+@RunWith(Parameterized::class)
+@OptIn(ExperimentalFoundationApi::class)
+class LazyListAnimateItemPlacementTest(private val config: Config) {
+
+    private val isVertical: Boolean get() = config.isVertical
+    private val reverseLayout: Boolean get() = config.reverseLayout
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val itemSize: Int = 50
+    private var itemSizeDp: Dp = Dp.Infinity
+    private val itemSize2: Int = 30
+    private var itemSize2Dp: Dp = Dp.Infinity
+    private val itemSize3: Int = 20
+    private var itemSize3Dp: Dp = Dp.Infinity
+    private val containerSize: Int = itemSize * 5
+    private var containerSizeDp: Dp = Dp.Infinity
+    private val spacing: Int = 10
+    private var spacingDp: Dp = Dp.Infinity
+    private val itemSizePlusSpacing = itemSize + spacing
+    private var itemSizePlusSpacingDp = Dp.Infinity
+    private lateinit var state: TvLazyListState
+
+    @Before
+    fun before() {
+        rule.mainClock.autoAdvance = false
+        with(rule.density) {
+            itemSizeDp = itemSize.toDp()
+            itemSize2Dp = itemSize2.toDp()
+            itemSize3Dp = itemSize3.toDp()
+            containerSizeDp = containerSize.toDp()
+            spacingDp = spacing.toDp()
+            itemSizePlusSpacingDp = itemSizePlusSpacing.toDp()
+        }
+    }
+
+    @Test
+    fun reorderTwoItems() {
+        var list by mutableStateOf(listOf(0, 1))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(0 to 0, 1 to itemSize)
+
+        rule.runOnIdle {
+            list = listOf(1, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to 0 + (itemSize * fraction).roundToInt(),
+                1 to itemSize - (itemSize * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun reorderTwoItems_layoutInfoHasFinalPositions() {
+        var list by mutableStateOf(listOf(0, 1))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertLayoutInfoPositions(0 to 0, 1 to itemSize)
+
+        rule.runOnIdle {
+            list = listOf(1, 0)
+        }
+
+        onAnimationFrame {
+            // fraction doesn't affect the offsets in layout info
+            assertLayoutInfoPositions(1 to 0, 0 to itemSize)
+        }
+    }
+
+    @Test
+    fun reorderFirstAndLastItems() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to 0,
+            1 to itemSize,
+            2 to itemSize * 2,
+            3 to itemSize * 3,
+            4 to itemSize * 4,
+        )
+
+        rule.runOnIdle {
+            list = listOf(4, 1, 2, 3, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to 0 + (itemSize * 4 * fraction).roundToInt(),
+                1 to itemSize,
+                2 to itemSize * 2,
+                3 to itemSize * 3,
+                4 to itemSize * 4 - (itemSize * 4 * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveFirstItemToEndCausingAllItemsToAnimate() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to 0,
+            1 to itemSize,
+            2 to itemSize * 2,
+            3 to itemSize * 3,
+            4 to itemSize * 4,
+        )
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to 0 + (itemSize * 4 * fraction).roundToInt(),
+                1 to itemSize - (itemSize * fraction).roundToInt(),
+                2 to itemSize * 2 - (itemSize * fraction).roundToInt(),
+                3 to itemSize * 3 - (itemSize * fraction).roundToInt(),
+                4 to itemSize * 4 - (itemSize * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun itemSizeChangeAnimatesNextItems() {
+        var size by mutableStateOf(itemSizeDp)
+        rule.setContent {
+            LazyList(
+                minSize = itemSizeDp * 5,
+                maxSize = itemSizeDp * 5
+            ) {
+                items(listOf(0, 1, 2, 3), key = { it }) {
+                    Item(it, size = if (it == 1) size else itemSizeDp)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            size = itemSizeDp * 2
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        rule.onNodeWithTag("1")
+            .assertMainAxisSizeIsEqualTo(size)
+
+        onAnimationFrame { fraction ->
+            if (!reverseLayout) {
+                assertPositions(
+                    0 to 0,
+                    1 to itemSize,
+                    2 to itemSize * 2 + (itemSize * fraction).roundToInt(),
+                    3 to itemSize * 3 + (itemSize * fraction).roundToInt(),
+                    fraction = fraction,
+                    autoReverse = false
+                )
+            } else {
+                assertPositions(
+                    3 to itemSize - (itemSize * fraction).roundToInt(),
+                    2 to itemSize * 2 - (itemSize * fraction).roundToInt(),
+                    1 to itemSize * 3 - (itemSize * fraction).roundToInt(),
+                    0 to itemSize * 4,
+                    fraction = fraction,
+                    autoReverse = false
+                )
+            }
+        }
+    }
+
+    @Test
+    fun onlyItemsWithModifierAnimates() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it, animSpec = if (it == 1 || it == 3) AnimSpec else null)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to itemSize * 4,
+                1 to itemSize - (itemSize * fraction).roundToInt(),
+                2 to itemSize,
+                3 to itemSize * 3 - (itemSize * fraction).roundToInt(),
+                4 to itemSize * 3,
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animationsWithDifferentDurations() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    val duration = if (it == 1 || it == 3) Duration * 2 else Duration
+                    Item(it, animSpec = tween(duration.toInt(), easing = LinearEasing))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 4, 0)
+        }
+
+        onAnimationFrame(duration = Duration * 2) { fraction ->
+            val shorterAnimFraction = (fraction * 2).coerceAtMost(1f)
+            assertPositions(
+                0 to 0 + (itemSize * 4 * shorterAnimFraction).roundToInt(),
+                1 to itemSize - (itemSize * fraction).roundToInt(),
+                2 to itemSize * 2 - (itemSize * shorterAnimFraction).roundToInt(),
+                3 to itemSize * 3 - (itemSize * fraction).roundToInt(),
+                4 to itemSize * 4 - (itemSize * shorterAnimFraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun multipleChildrenPerItem() {
+        var list by mutableStateOf(listOf(0, 2))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                    Item(it + 1)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to 0,
+            1 to itemSize,
+            2 to itemSize * 2,
+            3 to itemSize * 3,
+        )
+
+        rule.runOnIdle {
+            list = listOf(2, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to 0 + (itemSize * 2 * fraction).roundToInt(),
+                1 to itemSize + (itemSize * 2 * fraction).roundToInt(),
+                2 to itemSize * 2 - (itemSize * 2 * fraction).roundToInt(),
+                3 to itemSize * 3 - (itemSize * 2 * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun multipleChildrenPerItemSomeDoNotAnimate() {
+        var list by mutableStateOf(listOf(0, 2))
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                    Item(it + 1, animSpec = null)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(2, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to 0 + (itemSize * 2 * fraction).roundToInt(),
+                1 to itemSize * 3,
+                2 to itemSize * 2 - (itemSize * 2 * fraction).roundToInt(),
+                3 to itemSize,
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animateArrangementChange() {
+        var arrangement by mutableStateOf(Arrangement.Center)
+        rule.setContent {
+            LazyList(
+                arrangement = arrangement,
+                minSize = itemSizeDp * 5,
+                maxSize = itemSizeDp * 5
+            ) {
+                items(listOf(1, 2, 3), key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            1 to itemSize,
+            2 to itemSize * 2,
+            3 to itemSize * 3,
+        )
+
+        rule.runOnIdle {
+            arrangement = Arrangement.SpaceBetween
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                1 to itemSize - (itemSize * fraction).roundToInt(),
+                2 to itemSize * 2,
+                3 to itemSize * 3 + (itemSize * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheBottomOutsideOfBounds() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        rule.setContent {
+            LazyList(maxSize = itemSizeDp * 3) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to 0,
+            1 to itemSize,
+            2 to itemSize * 2
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 4, 2, 3, 1, 5)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset = itemSize + (itemSize * 3 * fraction).roundToInt()
+            val item4Offset = itemSize * 4 - (itemSize * 3 * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, Int>>().apply {
+                add(0 to 0)
+                if (item1Offset < itemSize * 3) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(2 to itemSize * 2)
+                if (item4Offset < itemSize * 3) {
+                    add(4 to item4Offset)
+                } else {
+                    rule.onNodeWithTag("4").assertIsNotDisplayed()
+                }
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheTopOutsideOfBounds() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        rule.setContent {
+            LazyList(maxSize = itemSizeDp * 3f, startIndex = 3) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            3 to 0,
+            4 to itemSize,
+            5 to itemSize * 2
+        )
+
+        rule.runOnIdle {
+            list = listOf(2, 4, 0, 3, 1, 5)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset = itemSize * -2 + (itemSize * 3 * fraction).roundToInt()
+            val item4Offset = itemSize - (itemSize * 3 * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, Int>>().apply {
+                if (item4Offset > -itemSize) {
+                    add(4 to item4Offset)
+                } else {
+                    rule.onNodeWithTag("4").assertIsNotDisplayed()
+                }
+                add(3 to 0)
+                if (item1Offset > -itemSize) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(5 to itemSize * 2)
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveFirstItemToEndCausingAllItemsToAnimate_withSpacing() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3))
+        rule.setContent {
+            LazyList(arrangement = Arrangement.spacedBy(spacingDp)) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(1, 2, 3, 0)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to 0 + (itemSizePlusSpacing * 3 * fraction).roundToInt(),
+                1 to itemSizePlusSpacing - (itemSizePlusSpacing * fraction).roundToInt(),
+                2 to itemSizePlusSpacing * 2 - (itemSizePlusSpacing * fraction).roundToInt(),
+                3 to itemSizePlusSpacing * 3 - (itemSizePlusSpacing * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheBottomOutsideOfBounds_withSpacing() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        rule.setContent {
+            LazyList(
+                maxSize = itemSizeDp * 3 + spacingDp * 2,
+                arrangement = Arrangement.spacedBy(spacingDp)
+            ) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            0 to 0,
+            1 to itemSizePlusSpacing,
+            2 to itemSizePlusSpacing * 2
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 4, 2, 3, 1, 5)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset =
+                itemSizePlusSpacing + (itemSizePlusSpacing * 3 * fraction).roundToInt()
+            val item4Offset =
+                itemSizePlusSpacing * 4 - (itemSizePlusSpacing * 3 * fraction).roundToInt()
+            val screenSize = itemSize * 3 + spacing * 2
+            val expected = mutableListOf<Pair<Any, Int>>().apply {
+                add(0 to 0)
+                if (item1Offset < screenSize) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(2 to itemSizePlusSpacing * 2)
+                if (item4Offset < screenSize) {
+                    add(4 to item4Offset)
+                } else {
+                    rule.onNodeWithTag("4").assertIsNotDisplayed()
+                }
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheTopOutsideOfBounds_withSpacing() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5, 6, 7))
+        rule.setContent {
+            LazyList(
+                maxSize = itemSizeDp * 3 + spacingDp * 2,
+                startIndex = 3,
+                arrangement = Arrangement.spacedBy(spacingDp)
+            ) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPositions(
+            3 to 0,
+            4 to itemSizePlusSpacing,
+            5 to itemSizePlusSpacing * 2
+        )
+
+        rule.runOnIdle {
+            list = listOf(2, 4, 0, 3, 1, 5, 6, 7)
+        }
+
+        onAnimationFrame { fraction ->
+            val item1Offset =
+                itemSizePlusSpacing * -2 + (itemSizePlusSpacing * 3 * fraction).roundToInt()
+            val item4Offset =
+                (itemSizePlusSpacing - itemSizePlusSpacing * 3 * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, Int>>().apply {
+                if (item4Offset > -itemSize) {
+                    add(4 to item4Offset)
+                } else {
+                    rule.onNodeWithTag("4").assertIsNotDisplayed()
+                }
+                add(3 to 0)
+                if (item1Offset > -itemSize) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(5 to itemSizePlusSpacing * 2)
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheTopOutsideOfBounds_differentSizes() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        rule.setContent {
+            LazyList(maxSize = itemSize2Dp + itemSize3Dp + itemSizeDp, startIndex = 3) {
+                items(list, key = { it }) {
+                    val size =
+                        if (it == 3) itemSize2Dp else if (it == 1) itemSize3Dp else itemSizeDp
+                    Item(it, size = size)
+                }
+            }
+        }
+
+        val item3Size = itemSize2
+        val item4Size = itemSize
+        assertPositions(
+            3 to 0,
+            4 to item3Size,
+            5 to item3Size + item4Size
+        )
+
+        rule.runOnIdle {
+            // swap 4 and 1
+            list = listOf(0, 4, 2, 3, 1, 5)
+        }
+
+        onAnimationFrame { fraction ->
+            rule.onNodeWithTag("2").assertDoesNotExist()
+            // item 2 was between 1 and 3 but we don't compose it and don't know the real size,
+            // so we use an average size.
+            val item2Size = (itemSize + itemSize2 + itemSize3) / 3
+            val item1Size = itemSize3 /* the real size of the item 1 */
+            val startItem1Offset = -item1Size - item2Size
+            val item1Offset =
+                startItem1Offset + ((itemSize2 - startItem1Offset) * fraction).roundToInt()
+            val endItem4Offset = -item4Size - item2Size
+            val item4Offset = item3Size - ((item3Size - endItem4Offset) * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, Int>>().apply {
+                if (item4Offset > -item4Size) {
+                    add(4 to item4Offset)
+                } else {
+                    rule.onNodeWithTag("4").assertIsNotDisplayed()
+                }
+                add(3 to 0)
+                if (item1Offset > -item1Size) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(5 to item3Size + item4Size - ((item4Size - item1Size) * fraction).roundToInt())
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToTheBottomOutsideOfBounds_differentSizes() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        val listSize = itemSize2 + itemSize3 + itemSize - 1
+        val listSizeDp = with(rule.density) { listSize.toDp() }
+        rule.setContent {
+            LazyList(maxSize = listSizeDp) {
+                items(list, key = { it }) {
+                    val size =
+                        if (it == 0) itemSize2Dp else if (it == 4) itemSize3Dp else itemSizeDp
+                    Item(it, size = size)
+                }
+            }
+        }
+
+        val item0Size = itemSize2
+        val item1Size = itemSize
+        assertPositions(
+            0 to 0,
+            1 to item0Size,
+            2 to item0Size + item1Size
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 4, 2, 3, 1, 5)
+        }
+
+        onAnimationFrame { fraction ->
+            val item2Size = itemSize
+            val item4Size = itemSize3
+            // item 3 was between 2 and 4 but we don't compose it and don't know the real size,
+            // so we use an average size.
+            val item3Size = (itemSize + itemSize2 + itemSize3) / 3
+            val startItem4Offset = item0Size + item1Size + item2Size + item3Size
+            val endItem1Offset = item0Size + item4Size + item2Size + item3Size
+            val item1Offset =
+                item0Size + ((endItem1Offset - item0Size) * fraction).roundToInt()
+            val item4Offset =
+                startItem4Offset - ((startItem4Offset - item0Size) * fraction).roundToInt()
+            val expected = mutableListOf<Pair<Any, Int>>().apply {
+                add(0 to 0)
+                if (item1Offset < listSize) {
+                    add(1 to item1Offset)
+                } else {
+                    rule.onNodeWithTag("1").assertIsNotDisplayed()
+                }
+                add(2 to item0Size + item1Size - ((item1Size - item4Size) * fraction).roundToInt())
+                if (item4Offset < listSize) {
+                    add(4 to item4Offset)
+                } else {
+                    rule.onNodeWithTag("4").assertIsNotDisplayed()
+                }
+            }
+            assertPositions(
+                expected = expected.toTypedArray(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animateAlignmentChange() {
+        var alignment by mutableStateOf(CrossAxisAlignment.End)
+        rule.setContent {
+            LazyList(
+                crossAxisAlignment = alignment,
+                crossAxisSize = itemSizeDp
+            ) {
+                items(listOf(1, 2, 3), key = { it }) {
+                    val crossAxisSize =
+                        if (it == 1) itemSizeDp else if (it == 2) itemSize2Dp else itemSize3Dp
+                    Item(it, crossAxisSize = crossAxisSize)
+                }
+            }
+        }
+
+        val item2Start = itemSize - itemSize2
+        val item3Start = itemSize - itemSize3
+        assertPositions(
+            1 to 0,
+            2 to itemSize,
+            3 to itemSize * 2,
+            crossAxis = listOf(
+                1 to 0,
+                2 to item2Start,
+                3 to item3Start,
+            )
+        )
+
+        rule.runOnIdle {
+            alignment = CrossAxisAlignment.Center
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        val item2End = itemSize / 2 - itemSize2 / 2
+        val item3End = itemSize / 2 - itemSize3 / 2
+        onAnimationFrame { fraction ->
+            assertPositions(
+                1 to 0,
+                2 to itemSize,
+                3 to itemSize * 2,
+                crossAxis = listOf(
+                    1 to 0,
+                    2 to item2Start + ((item2End - item2Start) * fraction).roundToInt(),
+                    3 to item3Start + ((item3End - item3Start) * fraction).roundToInt(),
+                ),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animateAlignmentChange_multipleChildrenPerItem() {
+        var alignment by mutableStateOf(CrossAxisAlignment.Start)
+        rule.setContent {
+            LazyList(
+                crossAxisAlignment = alignment,
+                crossAxisSize = itemSizeDp * 2
+            ) {
+                items(1) {
+                    listOf(1, 2, 3).forEach {
+                        val crossAxisSize =
+                            if (it == 1) itemSizeDp else if (it == 2) itemSize2Dp else itemSize3Dp
+                        Item(it, crossAxisSize = crossAxisSize)
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            alignment = CrossAxisAlignment.End
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        val containerSize = itemSize * 2
+        onAnimationFrame { fraction ->
+            assertPositions(
+                1 to 0,
+                2 to itemSize,
+                3 to itemSize * 2,
+                crossAxis = listOf(
+                    1 to ((containerSize - itemSize) * fraction).roundToInt(),
+                    2 to ((containerSize - itemSize2) * fraction).roundToInt(),
+                    3 to ((containerSize - itemSize3) * fraction).roundToInt()
+                ),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun animateAlignmentChange_rtl() {
+        // this test is not applicable to LazyRow
+        assumeTrue(isVertical)
+
+        var alignment by mutableStateOf(CrossAxisAlignment.End)
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                LazyList(
+                    crossAxisAlignment = alignment,
+                    crossAxisSize = itemSizeDp
+                ) {
+                    items(listOf(1, 2, 3), key = { it }) {
+                        val crossAxisSize =
+                            if (it == 1) itemSizeDp else if (it == 2) itemSize2Dp else itemSize3Dp
+                        Item(it, crossAxisSize = crossAxisSize)
+                    }
+                }
+            }
+        }
+
+        assertPositions(
+            1 to 0,
+            2 to itemSize,
+            3 to itemSize * 2,
+            crossAxis = listOf(
+                1 to 0,
+                2 to 0,
+                3 to 0,
+            )
+        )
+
+        rule.runOnIdle {
+            alignment = CrossAxisAlignment.Center
+        }
+        rule.mainClock.advanceTimeByFrame()
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                1 to 0,
+                2 to itemSize,
+                3 to itemSize * 2,
+                crossAxis = listOf(
+                    1 to 0,
+                    2 to ((itemSize / 2 - itemSize2 / 2) * fraction).roundToInt(),
+                    3 to ((itemSize / 2 - itemSize3 / 2) * fraction).roundToInt(),
+                ),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun moveItemToEndCausingNextItemsToAnimate_withContentPadding() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val rawStartPadding = 8
+        val rawEndPadding = 12
+        val (startPaddingDp, endPaddingDp) = with(rule.density) {
+            rawStartPadding.toDp() to rawEndPadding.toDp()
+        }
+        rule.setContent {
+            LazyList(startPadding = startPaddingDp, endPadding = endPaddingDp) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        val startPadding = if (reverseLayout) rawEndPadding else rawStartPadding
+        assertPositions(
+            0 to startPadding,
+            1 to startPadding + itemSize,
+            2 to startPadding + itemSize * 2,
+            3 to startPadding + itemSize * 3,
+            4 to startPadding + itemSize * 4,
+        )
+
+        rule.runOnIdle {
+            list = listOf(0, 2, 3, 4, 1)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to startPadding,
+                1 to startPadding + itemSize + (itemSize * 3 * fraction).roundToInt(),
+                2 to startPadding + itemSize * 2 - (itemSize * fraction).roundToInt(),
+                3 to startPadding + itemSize * 3 - (itemSize * fraction).roundToInt(),
+                4 to startPadding + itemSize * 4 - (itemSize * fraction).roundToInt(),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun reorderFirstAndLastItems_noNewLayoutInfoProduced() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+
+        var measurePasses = 0
+        rule.setContent {
+            LazyList {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+            LaunchedEffect(Unit) {
+                snapshotFlow { state.layoutInfo }
+                    .collect {
+                        measurePasses++
+                    }
+            }
+        }
+
+        rule.runOnIdle {
+            list = listOf(4, 1, 2, 3, 0)
+        }
+
+        var startMeasurePasses = Int.MIN_VALUE
+        onAnimationFrame { fraction ->
+            if (fraction == 0f) {
+                startMeasurePasses = measurePasses
+            }
+        }
+        rule.mainClock.advanceTimeByFrame()
+        // new layoutInfo is produced on every remeasure of Lazy lists.
+        // but we want to avoid remeasuring and only do relayout on each animation frame.
+        // two extra measures are possible as we switch inProgress flag.
+        assertThat(measurePasses).isAtMost(startMeasurePasses + 2)
+    }
+
+    @Test
+    fun noAnimationWhenScrollOtherPosition() {
+        rule.setContent {
+            LazyList(maxSize = itemSizeDp * 3) {
+                items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(0, itemSize / 2)
+            }
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                0 to -itemSize / 2,
+                1 to itemSize / 2,
+                2 to itemSize * 3 / 2,
+                3 to itemSize * 5 / 2,
+                fraction = fraction
+            )
+        }
+    }
+
+    private fun assertPositions(
+        vararg expected: Pair<Any, Int>,
+        crossAxis: List<Pair<Any, Int>>? = null,
+        fraction: Float? = null,
+        autoReverse: Boolean = reverseLayout
+    ) {
+        with(rule.density) {
+            val actual = expected.map {
+                val actualOffset = rule.onNodeWithTag(it.first.toString())
+                    .getUnclippedBoundsInRoot().let { bounds ->
+                        val offset = if (isVertical) bounds.top else bounds.left
+                        if (offset == Dp.Unspecified) Int.MIN_VALUE else offset.roundToPx()
+                    }
+                it.first to actualOffset
+            }
+            val subject = if (fraction == null) {
+                assertThat(actual)
+            } else {
+                assertWithMessage("Fraction=$fraction").that(actual)
+            }
+            subject.isEqualTo(
+                listOf(*expected).let { list ->
+                    if (!autoReverse) {
+                        list
+                    } else {
+                        val containerBounds = rule.onNodeWithTag(ContainerTag).getBoundsInRoot()
+                        val mainAxisSize =
+                            if (isVertical) containerBounds.height else containerBounds.width
+                        val mainAxisSizePx = with(rule.density) { mainAxisSize.roundToPx() }
+                        list.map {
+                            val itemSize = rule.onNodeWithTag(it.first.toString())
+                                .getUnclippedBoundsInRoot().let { bounds ->
+                                    (if (isVertical) bounds.height else bounds.width).roundToPx()
+                                }
+                            it.first to (mainAxisSizePx - itemSize - it.second)
+                        }
+                    }
+                }
+            )
+            if (crossAxis != null) {
+                val actualCross = expected.map {
+                    val actualOffset = rule.onNodeWithTag(it.first.toString())
+                        .getUnclippedBoundsInRoot().let { bounds ->
+                            val offset = if (isVertical) bounds.left else bounds.top
+                            if (offset == Dp.Unspecified) Int.MIN_VALUE else offset.roundToPx()
+                        }
+                    it.first to actualOffset
+                }
+                assertWithMessage(
+                    "CrossAxis" + if (fraction != null) "for fraction=$fraction" else ""
+                )
+                    .that(actualCross)
+                    .isEqualTo(crossAxis)
+            }
+        }
+    }
+
+    private fun assertLayoutInfoPositions(vararg offsets: Pair<Any, Int>) {
+        rule.runOnIdle {
+            assertThat(visibleItemsOffsets).isEqualTo(listOf(*offsets))
+        }
+    }
+
+    private val visibleItemsOffsets: List<Pair<Any, Int>>
+        get() = state.layoutInfo.visibleItemsInfo.map {
+            it.key to it.offset
+        }
+
+    private fun onAnimationFrame(duration: Long = Duration, onFrame: (fraction: Float) -> Unit) {
+        require(duration.mod(FrameDuration) == 0L)
+        rule.waitForIdle()
+        rule.mainClock.advanceTimeByFrame()
+        var expectedTime = rule.mainClock.currentTime
+        for (i in 0..duration step FrameDuration) {
+            onFrame(i / duration.toFloat())
+            rule.mainClock.advanceTimeBy(FrameDuration)
+            expectedTime += FrameDuration
+            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            rule.waitForIdle()
+        }
+    }
+
+    @Composable
+    private fun LazyList(
+        arrangement: Arrangement.HorizontalOrVertical? = null,
+        minSize: Dp = 0.dp,
+        maxSize: Dp = containerSizeDp,
+        startIndex: Int = 0,
+        crossAxisSize: Dp = Dp.Unspecified,
+        crossAxisAlignment: CrossAxisAlignment = CrossAxisAlignment.Start,
+        startPadding: Dp = 0.dp,
+        endPadding: Dp = 0.dp,
+        content: TvLazyListScope.() -> Unit
+    ) {
+        state = rememberLazyListState(startIndex)
+        if (isVertical) {
+            val verticalArrangement =
+                arrangement ?: if (!reverseLayout) Arrangement.Top else Arrangement.Bottom
+            val horizontalAlignment = if (crossAxisAlignment == CrossAxisAlignment.Start) {
+                Alignment.Start
+            } else if (crossAxisAlignment == CrossAxisAlignment.Center) {
+                Alignment.CenterHorizontally
+            } else {
+                Alignment.End
+            }
+            TvLazyColumn(
+                state = state,
+                modifier = Modifier
+                    .requiredHeightIn(min = minSize, max = maxSize)
+                    .then(
+                        if (crossAxisSize != Dp.Unspecified) {
+                            Modifier.requiredWidth(crossAxisSize)
+                        } else {
+                            Modifier.fillMaxWidth()
+                        }
+                    )
+                    .testTag(ContainerTag),
+                verticalArrangement = verticalArrangement,
+                horizontalAlignment = horizontalAlignment,
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(top = startPadding, bottom = endPadding),
+                pivotOffsets = PivotOffsets(parentFraction = 0f),
+                content = content
+            )
+        } else {
+            val horizontalArrangement =
+                arrangement ?: if (!reverseLayout) Arrangement.Start else Arrangement.End
+            val verticalAlignment = if (crossAxisAlignment == CrossAxisAlignment.Start) {
+                Alignment.Top
+            } else if (crossAxisAlignment == CrossAxisAlignment.Center) {
+                Alignment.CenterVertically
+            } else {
+                Alignment.Bottom
+            }
+            TvLazyRow(
+                state = state,
+                modifier = Modifier
+                    .requiredWidthIn(min = minSize, max = maxSize)
+                    .then(
+                        if (crossAxisSize != Dp.Unspecified) {
+                            Modifier.requiredHeight(crossAxisSize)
+                        } else {
+                            Modifier.fillMaxHeight()
+                        }
+                    )
+                    .testTag(ContainerTag),
+                horizontalArrangement = horizontalArrangement,
+                verticalAlignment = verticalAlignment,
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(start = startPadding, end = endPadding),
+                pivotOffsets = PivotOffsets(parentFraction = 0f),
+                content = content
+            )
+        }
+    }
+
+    @Composable
+    private fun LazyItemScope.Item(
+        tag: Int,
+        size: Dp = itemSizeDp,
+        crossAxisSize: Dp = size,
+        animSpec: FiniteAnimationSpec<IntOffset>? = AnimSpec
+    ) {
+        Box(
+            Modifier
+                .then(
+                    if (isVertical) {
+                        Modifier.requiredHeight(size).requiredWidth(crossAxisSize)
+                    } else {
+                        Modifier.requiredWidth(size).requiredHeight(crossAxisSize)
+                    }
+                )
+                .testTag(tag.toString())
+                .then(
+                    if (animSpec != null) {
+                        Modifier.animateItemPlacement(animSpec)
+                    } else {
+                        Modifier
+                    }
+                )
+        )
+    }
+
+    private fun SemanticsNodeInteraction.assertMainAxisSizeIsEqualTo(
+        expected: Dp
+    ): SemanticsNodeInteraction {
+        return if (isVertical) assertHeightIsEqualTo(expected) else assertWidthIsEqualTo(expected)
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() = arrayOf(
+            Config(isVertical = true, reverseLayout = false),
+            Config(isVertical = false, reverseLayout = false),
+            Config(isVertical = true, reverseLayout = true),
+            Config(isVertical = false, reverseLayout = true),
+        )
+
+        class Config(
+            val isVertical: Boolean,
+            val reverseLayout: Boolean
+        ) {
+            override fun toString() =
+                (if (isVertical) "LazyColumn" else "LazyRow") +
+                    (if (reverseLayout) "(reverse)" else "")
+        }
+    }
+}
+
+private val FrameDuration = 16L
+private val Duration = 400L
+private val AnimSpec = tween<IntOffset>(Duration.toInt(), easing = LinearEasing)
+private val ContainerTag = "container"
+
+private enum class CrossAxisAlignment {
+    Start,
+    End,
+    Center
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListLayoutInfoTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListLayoutInfoTest.kt
new file mode 100644
index 0000000..5e39177
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListLayoutInfoTest.kt
@@ -0,0 +1,461 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+class LazyListLayoutInfoTest(
+    param: LayoutInfoTestParam
+) : BaseLazyListTestWithOrientation(param.orientation) {
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun initParameters(): Array<Any> = arrayOf(
+            LayoutInfoTestParam(Orientation.Vertical, false),
+            LayoutInfoTestParam(Orientation.Vertical, true),
+            LayoutInfoTestParam(Orientation.Horizontal, false),
+            LayoutInfoTestParam(Orientation.Horizontal, true),
+        )
+    }
+
+    private val reverseLayout = param.reverseLayout
+
+    private var itemSizePx: Int = 50
+    private var itemSizeDp: Dp = Dp.Infinity
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSizeDp = itemSizePx.toDp()
+        }
+    }
+
+    @Test
+    fun visibleItemsAreCorrect() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.requiredSize(itemSizeDp * 3.5f)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.requiredSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            state.layoutInfo.assertVisibleItems(count = 4)
+        }
+    }
+
+    @Test
+    fun visibleItemsAreCorrectAfterScroll() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.requiredSize(itemSizeDp * 3.5f)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.requiredSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(1, 10)
+            }
+            state.layoutInfo.assertVisibleItems(count = 4, startIndex = 1, startOffset = -10)
+        }
+    }
+
+    @Test
+    fun visibleItemsAreCorrectWithSpacing() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                spacedBy = itemSizeDp,
+                modifier = Modifier.requiredSize(itemSizeDp * 3.5f)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.requiredSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            state.layoutInfo.assertVisibleItems(count = 2, spacing = itemSizePx)
+        }
+    }
+
+    @Composable
+    fun ObservingFun(state: TvLazyListState, currentInfo: StableRef<LazyListLayoutInfo?>) {
+        currentInfo.value = state.layoutInfo
+    }
+    @Test
+    fun visibleItemsAreObservableWhenWeScroll() {
+        lateinit var state: TvLazyListState
+        val currentInfo = StableRef<LazyListLayoutInfo?>(null)
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.requiredSize(itemSizeDp * 3.5f)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.requiredSize(itemSizeDp))
+                }
+            }
+            ObservingFun(state, currentInfo)
+        }
+
+        rule.runOnIdle {
+            // empty it here and scrolling should invoke observingFun again
+            currentInfo.value = null
+            runBlocking {
+                state.scrollToItem(1, 0)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(currentInfo.value).isNotNull()
+            currentInfo.value!!.assertVisibleItems(count = 4, startIndex = 1)
+        }
+    }
+
+    @Test
+    fun visibleItemsAreObservableWhenResize() {
+        lateinit var state: TvLazyListState
+        var size by mutableStateOf(itemSizeDp * 2)
+        var currentInfo: LazyListLayoutInfo? = null
+        @Composable
+        fun observingFun() {
+            currentInfo = state.layoutInfo
+        }
+        rule.setContent {
+            LazyColumnOrRow(
+                reverseLayout = reverseLayout,
+                state = rememberLazyListState().also { state = it }
+            ) {
+                item {
+                    Box(Modifier.requiredSize(size))
+                }
+            }
+            observingFun()
+        }
+
+        rule.runOnIdle {
+            assertThat(currentInfo).isNotNull()
+            currentInfo!!.assertVisibleItems(count = 1, expectedSize = itemSizePx * 2)
+            currentInfo = null
+            size = itemSizeDp
+        }
+
+        rule.runOnIdle {
+            assertThat(currentInfo).isNotNull()
+            currentInfo!!.assertVisibleItems(count = 1, expectedSize = itemSizePx)
+        }
+    }
+
+    @Test
+    fun totalCountIsCorrect() {
+        var count by mutableStateOf(10)
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                reverseLayout = reverseLayout,
+                state = rememberLazyListState().also { state = it }
+            ) {
+                items((0 until count).toList()) {
+                    Box(Modifier.requiredSize(10.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.totalItemsCount).isEqualTo(10)
+            count = 20
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.totalItemsCount).isEqualTo(20)
+        }
+    }
+
+    @Test
+    fun viewportOffsetsAndSizeAreCorrect() {
+        val sizePx = 45
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(sizeDp).crossAxisSize(sizeDp * 2),
+                reverseLayout = reverseLayout,
+                state = rememberLazyListState().also { state = it }
+            ) {
+                items((0..3).toList()) {
+                    Box(Modifier.requiredSize(sizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(0)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (vertical) IntSize(sizePx * 2, sizePx) else IntSize(sizePx, sizePx * 2)
+            )
+        }
+    }
+
+    @Test
+    fun viewportOffsetsAndSizeAreCorrectWithContentPadding() {
+        val sizePx = 45
+        val startPaddingPx = 10
+        val endPaddingPx = 15
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        val beforeContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) startPaddingPx.toDp() else endPaddingPx.toDp()
+        }
+        val afterContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) endPaddingPx.toDp() else startPaddingPx.toDp()
+        }
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(sizeDp).crossAxisSize(sizeDp * 2),
+                contentPadding = PaddingValues(
+                    beforeContent = beforeContentPaddingDp,
+                    afterContent = afterContentPaddingDp,
+                    beforeContentCrossAxis = 2.dp,
+                    afterContentCrossAxis = 2.dp
+                ),
+                reverseLayout = reverseLayout,
+                state = rememberLazyListState().also { state = it }
+            ) {
+                items((0..3).toList()) {
+                    Box(Modifier.requiredSize(sizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(-startPaddingPx)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx - startPaddingPx)
+            assertThat(state.layoutInfo.afterContentPadding).isEqualTo(endPaddingPx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (vertical) IntSize(sizePx * 2, sizePx) else IntSize(sizePx, sizePx * 2)
+            )
+        }
+    }
+
+    @Test
+    fun emptyItemsInVisibleItemsInfo() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it }
+            ) {
+                item { Box(Modifier) }
+                item { }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.visibleItemsInfo.size).isEqualTo(2)
+            assertThat(state.layoutInfo.visibleItemsInfo.first().index).isEqualTo(0)
+            assertThat(state.layoutInfo.visibleItemsInfo.last().index).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun emptyContent() {
+        lateinit var state: TvLazyListState
+        val sizePx = 45
+        val startPaddingPx = 10
+        val endPaddingPx = 15
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        val beforeContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) startPaddingPx.toDp() else endPaddingPx.toDp()
+        }
+        val afterContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) endPaddingPx.toDp() else startPaddingPx.toDp()
+        }
+        rule.setContent {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(sizeDp).crossAxisSize(sizeDp * 2),
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(
+                    beforeContent = beforeContentPaddingDp,
+                    afterContent = afterContentPaddingDp
+                )
+            ) {
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(-startPaddingPx)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx - startPaddingPx)
+            assertThat(state.layoutInfo.beforeContentPadding).isEqualTo(startPaddingPx)
+            assertThat(state.layoutInfo.afterContentPadding).isEqualTo(endPaddingPx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (vertical) IntSize(sizePx * 2, sizePx) else IntSize(sizePx, sizePx * 2)
+            )
+        }
+    }
+
+    @Test
+    fun viewportIsLargerThenTheContent() {
+        lateinit var state: TvLazyListState
+        val sizePx = 45
+        val startPaddingPx = 10
+        val endPaddingPx = 15
+        val sizeDp = with(rule.density) { sizePx.toDp() }
+        val beforeContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) startPaddingPx.toDp() else endPaddingPx.toDp()
+        }
+        val afterContentPaddingDp = with(rule.density) {
+            if (!reverseLayout) endPaddingPx.toDp() else startPaddingPx.toDp()
+        }
+        rule.setContent {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(sizeDp).crossAxisSize(sizeDp * 2),
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                contentPadding = PaddingValues(
+                    beforeContent = beforeContentPaddingDp,
+                    afterContent = afterContentPaddingDp
+                )
+            ) {
+                item {
+                    Box(Modifier.size(sizeDp / 2))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.viewportStartOffset).isEqualTo(-startPaddingPx)
+            assertThat(state.layoutInfo.viewportEndOffset).isEqualTo(sizePx - startPaddingPx)
+            assertThat(state.layoutInfo.beforeContentPadding).isEqualTo(startPaddingPx)
+            assertThat(state.layoutInfo.afterContentPadding).isEqualTo(endPaddingPx)
+            assertThat(state.layoutInfo.viewportSize).isEqualTo(
+                if (vertical) IntSize(sizePx * 2, sizePx) else IntSize(sizePx, sizePx * 2)
+            )
+        }
+    }
+
+    @Test
+    fun reverseLayoutIsCorrect() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it },
+                reverseLayout = reverseLayout,
+                modifier = Modifier.requiredSize(itemSizeDp * 3.5f)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.requiredSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.reverseLayout).isEqualTo(reverseLayout)
+        }
+    }
+
+    @Test
+    fun orientationIsCorrect() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSizeDp * 3.5f)
+            ) {
+                items((0..5).toList()) {
+                    Box(Modifier.requiredSize(itemSizeDp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.layoutInfo.orientation)
+                .isEqualTo(if (vertical) Orientation.Vertical else Orientation.Horizontal)
+        }
+    }
+
+    fun LazyListLayoutInfo.assertVisibleItems(
+        count: Int,
+        startIndex: Int = 0,
+        startOffset: Int = 0,
+        expectedSize: Int = itemSizePx,
+        spacing: Int = 0
+    ) {
+        assertThat(visibleItemsInfo.size).isEqualTo(count)
+        var currentIndex = startIndex
+        var currentOffset = startOffset
+        visibleItemsInfo.forEach {
+            assertThat(it.index).isEqualTo(currentIndex)
+            assertWithMessage("Offset of item $currentIndex").that(it.offset)
+                .isEqualTo(currentOffset)
+            assertThat(it.size).isEqualTo(expectedSize)
+            currentIndex++
+            currentOffset += it.size + spacing
+        }
+    }
+}
+
+class LayoutInfoTestParam(
+    val orientation: Orientation,
+    val reverseLayout: Boolean
+) {
+    override fun toString(): String {
+        return "orientation=$orientation;reverseLayout=$reverseLayout"
+    }
+}
+
+@Stable
+class StableRef<T>(var value: T)
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListPrefetcherTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListPrefetcherTest.kt
new file mode 100644
index 0000000..db1d248
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListPrefetcherTest.kt
@@ -0,0 +1,380 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Remeasurement
+import androidx.compose.ui.layout.RemeasurementModifier
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.LargeTest
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@LargeTest
+@RunWith(Parameterized::class)
+class LazyListPrefetcherTest(
+    orientation: Orientation
+) : BaseLazyListTestWithOrientation(orientation) {
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun initParameters(): Array<Any> = arrayOf(
+            Orientation.Vertical,
+            Orientation.Horizontal,
+        )
+    }
+
+    val itemsSizePx = 30
+    val itemsSizeDp = with(rule.density) { itemsSizePx.toDp() }
+
+    lateinit var state: TvLazyListState
+
+    @Test
+    fun notPrefetchingForwardInitially() {
+        composeList()
+
+        rule.onNodeWithTag("2")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun notPrefetchingBackwardInitially() {
+        composeList(firstItem = 2)
+
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAfterSmallScroll() {
+        composeList()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(2)
+
+        rule.onNodeWithTag("2")
+            .assertExists()
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingBackwardAfterSmallScroll() {
+        composeList(firstItem = 2, itemOffset = 10)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-5f)
+            }
+        }
+
+        waitForPrefetch(1)
+
+        rule.onNodeWithTag("1")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAndBackward() {
+        composeList(firstItem = 1)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(3)
+
+        rule.onNodeWithTag("3")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-2f)
+                state.scrollBy(-1f)
+            }
+        }
+
+        waitForPrefetch(0)
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardTwice() {
+        composeList()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(2)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(itemsSizePx / 2f)
+                state.scrollBy(itemsSizePx / 2f)
+            }
+        }
+
+        waitForPrefetch(3)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("3")
+            .assertExists()
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingBackwardTwice() {
+        composeList(firstItem = 4)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-5f)
+            }
+        }
+
+        waitForPrefetch(2)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-itemsSizePx / 2f)
+                state.scrollBy(-itemsSizePx / 2f)
+            }
+        }
+
+        waitForPrefetch(1)
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAndBackwardReverseLayout() {
+        composeList(firstItem = 1, reverseLayout = true)
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(3)
+
+        rule.onNodeWithTag("3")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-2f)
+                state.scrollBy(-1f)
+            }
+        }
+
+        waitForPrefetch(0)
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun prefetchingForwardAndBackwardWithContentPadding() {
+        val halfItemSize = itemsSizeDp / 2f
+        composeList(
+            firstItem = 2,
+            itemOffset = 5,
+            contentPadding = PaddingValues(mainAxis = halfItemSize)
+        )
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(5f)
+            }
+        }
+
+        waitForPrefetch(3)
+
+        rule.onNodeWithTag("4")
+            .assertExists()
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollBy(-2f)
+            }
+        }
+
+        waitForPrefetch(0)
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+    }
+
+    @Test
+    fun disposingWhilePrefetchingScheduled() {
+        var emit = true
+        lateinit var remeasure: Remeasurement
+        rule.setContent {
+            SubcomposeLayout(
+                modifier = object : RemeasurementModifier {
+                    override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
+                        remeasure = remeasurement
+                    }
+                }
+            ) { constraints ->
+                val placeable = if (emit) {
+                    subcompose(Unit) {
+                        state = rememberLazyListState()
+                        LazyColumnOrRow(
+                            Modifier.mainAxisSize(itemsSizeDp * 1.5f),
+                            state,
+                        ) {
+                            items(1000) {
+                                Spacer(
+                                    Modifier
+                                        .mainAxisSize(itemsSizeDp)
+                                        .then(fillParentMaxCrossAxis())
+                                )
+                            }
+                        }
+                    }.first().measure(constraints)
+                } else {
+                    null
+                }
+                layout(constraints.maxWidth, constraints.maxHeight) {
+                    placeable?.place(0, 0)
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            // this will schedule the prefetching
+            runBlocking(AutoTestFrameClock()) {
+                state.scrollBy(itemsSizePx.toFloat())
+            }
+            // then we synchronously dispose LazyColumn
+            emit = false
+            remeasure.forceRemeasure()
+        }
+
+        rule.runOnIdle { }
+    }
+
+    private fun waitForPrefetch(index: Int) {
+        rule.waitUntil {
+            activeNodes.contains(index) && activeMeasuredNodes.contains(index)
+        }
+    }
+
+    private val activeNodes = mutableSetOf<Int>()
+    private val activeMeasuredNodes = mutableSetOf<Int>()
+
+    private fun composeList(
+        firstItem: Int = 0,
+        itemOffset: Int = 0,
+        reverseLayout: Boolean = false,
+        contentPadding: PaddingValues = PaddingValues(0.dp)
+    ) {
+        rule.setContent {
+            state = rememberLazyListState(
+                initialFirstVisibleItemIndex = firstItem,
+                initialFirstVisibleItemScrollOffset = itemOffset
+            )
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemsSizeDp * 1.5f),
+                state,
+                reverseLayout = reverseLayout,
+                contentPadding = contentPadding
+            ) {
+                items(100) {
+                    DisposableEffect(it) {
+                        activeNodes.add(it)
+                        onDispose {
+                            activeNodes.remove(it)
+                            activeMeasuredNodes.remove(it)
+                        }
+                    }
+                    Spacer(
+                        Modifier
+                            .mainAxisSize(itemsSizeDp)
+                            .fillMaxCrossAxis()
+                            .testTag("$it")
+                            .layout { measurable, constraints ->
+                                val placeable = measurable.measure(constraints)
+                                activeMeasuredNodes.add(it)
+                                layout(placeable.width, placeable.height) {
+                                    placeable.place(0, 0)
+                                }
+                            }
+                    )
+                }
+            }
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListSlotsReuseTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListSlotsReuseTest.kt
new file mode 100644
index 0000000..7e0f810
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListSlotsReuseTest.kt
@@ -0,0 +1,506 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.Truth
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class LazyListSlotsReuseTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    val itemsSizePx = 30f
+    val itemsSizeDp = with(rule.density) { itemsSizePx.toDp() }
+
+    @Test
+    fun scroll1ItemScrolledOffItemIsKeptForReuse() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(1)
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scroll2ItemsScrolledOffItemsAreKeptForReuse() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2)
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun checkMaxItemsKeptForReuse() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * (DefaultMaxItemsToRetain + 0.5f)),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(DefaultMaxItemsToRetain + 1)
+            }
+        }
+
+        repeat(DefaultMaxItemsToRetain) {
+            rule.onNodeWithTag("$it")
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+        rule.onNodeWithTag("$DefaultMaxItemsToRetain")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("${DefaultMaxItemsToRetain + 1}")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scroll3Items2OfScrolledOffItemsAreKeptForReuse() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                // after this step 0 and 1 are in reusable buffer
+                state.scrollToItem(2)
+
+                // this step requires one item and will take the last item from the buffer - item
+                // 1 plus will put 2 in the buffer. so expected buffer is items 2 and 0
+                state.scrollToItem(3)
+            }
+        }
+
+        // recycled
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        // in buffer
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("2")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun doMultipleScrollsOneByOne() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(1) // buffer is [0]
+                state.scrollToItem(2) // 0 used, buffer is [1]
+                state.scrollToItem(3) // 1 used, buffer is [2]
+                state.scrollToItem(4) // 2 used, buffer is [3]
+            }
+        }
+
+        // recycled
+        rule.onNodeWithTag("0")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("2")
+            .assertDoesNotExist()
+
+        // in buffer
+        rule.onNodeWithTag("3")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("5")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scrollBackwardOnce() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState(10)
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(8) // buffer is [10, 11]
+            }
+        }
+
+        // in buffer
+        rule.onNodeWithTag("10")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("11")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("8")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("9")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scrollBackwardOneByOne() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState(10)
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                            .focusable())
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(9) // buffer is [11]
+                state.scrollToItem(7) // 11 reused, buffer is [9]
+                state.scrollToItem(6) // 9 reused, buffer is [8]
+            }
+        }
+
+        // in buffer
+        rule.onNodeWithTag("8")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        // visible
+        rule.onNodeWithTag("6")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("7")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun scrollingBackReusesTheSameSlot() {
+        lateinit var state: TvLazyListState
+        var counter0 = 0
+        var counter1 = 10
+        var rememberedValue0 = -1
+        var rememberedValue1 = -1
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 1.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(100) {
+                    if (it == 0) {
+                        rememberedValue0 = remember { counter0++ }
+                    }
+                    if (it == 1) {
+                        rememberedValue1 = remember { counter1++ }
+                    }
+                    Box(
+                        Modifier.height(itemsSizeDp).fillParentMaxWidth().testTag("$it")
+                        .focusable())
+                }
+            }
+        }
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2) // buffer is [0, 1]
+                state.scrollToItem(0) // scrolled back, 0 and 1 are reused back. buffer: [2, 3]
+            }
+        }
+
+        rule.runOnIdle {
+            Truth.assertWithMessage("Item 0 restored remembered value is $rememberedValue0")
+                .that(rememberedValue0).isEqualTo(0)
+            Truth.assertWithMessage("Item 1 restored remembered value is $rememberedValue1")
+                .that(rememberedValue1).isEqualTo(10)
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("3")
+            .assertExists()
+            .assertIsNotDisplayed()
+    }
+
+    @Test
+    fun differentContentTypes() {
+        lateinit var state: TvLazyListState
+        val visibleItemsCount = (DefaultMaxItemsToRetain + 1) * 2
+        val startOfType1 = DefaultMaxItemsToRetain + 1
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * (visibleItemsCount - 0.5f)),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(
+                    100,
+                    contentType = { if (it >= startOfType1) 1 else 0 }
+                ) {
+                    Box(
+                        Modifier.height(itemsSizeDp).fillMaxWidth().testTag("$it").focusable())
+                }
+            }
+        }
+
+        for (i in 0 until visibleItemsCount) {
+            rule.onNodeWithTag("$i")
+                .assertIsDisplayed()
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(visibleItemsCount)
+            }
+        }
+
+        rule.onNodeWithTag("$visibleItemsCount")
+            .assertIsDisplayed()
+
+        // [DefaultMaxItemsToRetain] items of type 0 are left for reuse
+        for (i in 0 until DefaultMaxItemsToRetain) {
+            rule.onNodeWithTag("$i")
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+        rule.onNodeWithTag("$DefaultMaxItemsToRetain")
+            .assertDoesNotExist()
+
+        // and 7 items of type 1
+        for (i in startOfType1 until startOfType1 + DefaultMaxItemsToRetain) {
+            rule.onNodeWithTag("$i")
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+        rule.onNodeWithTag("${startOfType1 + DefaultMaxItemsToRetain}")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun differentTypesFromDifferentItemCalls() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            TvLazyColumn(
+                Modifier.height(itemsSizeDp * 2.5f),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                val content = @Composable { tag: String ->
+                    Spacer(Modifier.height(itemsSizeDp).width(10.dp).testTag(tag).focusable())
+                }
+                item(contentType = "not-to-reuse-0") {
+                    content("0")
+                }
+                item(contentType = "reuse") {
+                    content("1")
+                }
+                items(
+                    List(100) { it + 2 },
+                    contentType = { if (it == 10) "reuse" else "not-to-reuse-$it" }) {
+                    content("$it")
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2)
+                // now items 0 and 1 are put into reusables
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(9)
+                // item 10 should reuse slot 1
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertExists()
+            .assertIsNotDisplayed()
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("9")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("10")
+            .assertIsDisplayed()
+        rule.onNodeWithTag("11")
+            .assertIsDisplayed()
+    }
+}
+
+private val DefaultMaxItemsToRetain = 7
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListTest.kt
new file mode 100644
index 0000000..6b61bf4
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListTest.kt
@@ -0,0 +1,1733 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.list
+
+import android.os.Build
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.requiredSizeIn
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.WithTouchSlop
+import androidx.compose.testutils.assertPixels
+import androidx.compose.testutils.assertShape
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher.Companion.keyIsDefined
+import androidx.compose.ui.test.SemanticsMatcher.Companion.keyNotDefined
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.StateRestorationTester
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.tv.foundation.lazy.grid.keyPress
+import com.google.common.collect.Range
+import com.google.common.truth.IntegerSubject
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.concurrent.CountDownLatch
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@LargeTest
+@RunWith(Parameterized::class)
+class LazyListTest(orientation: Orientation) : BaseLazyListTestWithOrientation(orientation) {
+    private val LazyListTag = "LazyListTag"
+    private val firstItemTag = "firstItemTag"
+
+    @Test
+    fun lazyListShowsCombinedItems() {
+        val itemTestTag = "itemTestTag"
+        val items = listOf(1, 2).map { it.toString() }
+        val indexedItems = listOf(3, 4, 5)
+
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.mainAxisSize(200.dp)) {
+                item {
+                    Spacer(
+                        Modifier.mainAxisSize(40.dp)
+                            .then(fillParentMaxCrossAxis())
+                            .testTag(itemTestTag)
+                    )
+                }
+                items(items) {
+                    Spacer(Modifier.mainAxisSize(40.dp).then(fillParentMaxCrossAxis()).testTag(it))
+                }
+                itemsIndexed(indexedItems) { index, item ->
+                    Spacer(
+                        Modifier.mainAxisSize(41.dp).then(fillParentMaxCrossAxis())
+                            .testTag("$index-$item")
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(itemTestTag)
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("0-3")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("1-4")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2-5")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun lazyListAllowEmptyListItems() {
+        val itemTag = "itemTag"
+
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow {
+                items(emptyList<Any>()) { }
+                item {
+                    Spacer(Modifier.size(10.dp).testTag(itemTag))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(itemTag)
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun lazyListAllowsNullableItems() {
+        val items = listOf("1", null, "3")
+        val nullTestTag = "nullTestTag"
+
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.mainAxisSize(200.dp)) {
+                items(items) {
+                    if (it != null) {
+                        Spacer(
+                            Modifier.mainAxisSize(101.dp)
+                                .then(fillParentMaxCrossAxis())
+                                .testTag(it)
+                        )
+                    } else {
+                        Spacer(
+                            Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
+                                .testTag(nullTestTag)
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag(nullTestTag)
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun lazyListOnlyVisibleItemsAdded() {
+        val items = (1..4).map { it.toString() }
+
+        rule.setContentWithTestViewConfiguration {
+            Box(Modifier.mainAxisSize(200.dp)) {
+                LazyColumnOrRow(pivotOffsets = PivotOffsets(parentFraction = 0.4f)) {
+                    items(items) {
+                        Spacer(
+                            Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it)
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("4")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun lazyListScrollToShowItems123() {
+        val items = (1..4).map { it.toString() }
+        rule.setContentWithTestViewConfiguration {
+            Box(Modifier.mainAxisSize(200.dp)) {
+                LazyColumnOrRow(
+                    modifier = Modifier.testTag(LazyListTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0.3f)
+                ) {
+                    items(items) {
+                        Box(
+                            Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
+                                .testTag(it).focusable().border(3.dp, Color.Red)
+                        ) {
+                            BasicText(it)
+                        }
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag("1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("4")
+            .assertIsNotDisplayed()
+    }
+
+    @Test
+    fun lazyListScrollToHideFirstItem() {
+        val items = (1..4).map { it.toString() }
+        rule.setContentWithTestViewConfiguration {
+            Box(Modifier.mainAxisSize(200.dp)) {
+                LazyColumnOrRow(modifier = Modifier.testTag(LazyListTag)) {
+                    items(items) {
+                        Box(
+                            Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
+                                .testTag(it).focusable()
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag("1")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun lazyListScrollToShowItems234() {
+        val items = (1..4).map { it.toString() }
+
+        rule.setContentWithTestViewConfiguration {
+            Box(Modifier.mainAxisSize(200.dp)) {
+                LazyColumnOrRow(
+                    modifier = Modifier.testTag(LazyListTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0.3f)
+                ) {
+                    items(items) {
+                        Box(
+                            Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
+                                .testTag(it).focusable()
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(4)
+
+        rule.onNodeWithTag("1")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("4")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun lazyListWrapsContent() = with(rule.density) {
+        val itemInsideLazyList = "itemInsideLazyList"
+        val itemOutsideLazyList = "itemOutsideLazyList"
+        var sameSizeItems by mutableStateOf(true)
+
+        rule.setContentWithTestViewConfiguration {
+            Column {
+                LazyColumnOrRow(Modifier.testTag(LazyListTag)) {
+                    items(listOf(1, 2)) {
+                        if (it == 1) {
+                            Spacer(Modifier.size(50.dp).testTag(itemInsideLazyList))
+                        } else {
+                            Spacer(Modifier.size(if (sameSizeItems) 50.dp else 70.dp))
+                        }
+                    }
+                }
+                Spacer(Modifier.size(50.dp).testTag(itemOutsideLazyList))
+            }
+        }
+
+        rule.onNodeWithTag(itemInsideLazyList)
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag(itemOutsideLazyList)
+            .assertIsDisplayed()
+
+        var lazyListBounds = rule.onNodeWithTag(LazyListTag).getUnclippedBoundsInRoot()
+        var mainAxisEndBound = if (vertical) lazyListBounds.bottom else lazyListBounds.right
+        var crossAxisEndBound = if (vertical) lazyListBounds.right else lazyListBounds.bottom
+
+        assertThat(lazyListBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+        assertThat(mainAxisEndBound.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
+        assertThat(lazyListBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+        assertThat(crossAxisEndBound.roundToPx()).isWithin1PixelFrom(50.dp.roundToPx())
+
+        rule.runOnIdle {
+            sameSizeItems = false
+        }
+
+        rule.waitForIdle()
+
+        rule.onNodeWithTag(itemInsideLazyList)
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag(itemOutsideLazyList)
+            .assertIsDisplayed()
+
+        lazyListBounds = rule.onNodeWithTag(LazyListTag).getUnclippedBoundsInRoot()
+        mainAxisEndBound = if (vertical) lazyListBounds.bottom else lazyListBounds.right
+        crossAxisEndBound = if (vertical) lazyListBounds.right else lazyListBounds.bottom
+
+        assertThat(lazyListBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+        assertThat(mainAxisEndBound.roundToPx()).isWithin1PixelFrom(120.dp.roundToPx())
+        assertThat(lazyListBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+        assertThat(crossAxisEndBound.roundToPx()).isWithin1PixelFrom(70.dp.roundToPx())
+    }
+
+    @Test
+    fun compositionsAreDisposed_whenNodesAreScrolledOff() {
+        var composed: Boolean
+        var disposed = false
+        // Ten 31dp spacers in a 300dp list
+        val latch = CountDownLatch(10)
+
+        rule.setContentWithTestViewConfiguration {
+            // Fixed size to eliminate device size as a factor
+            Box(Modifier.testTag(LazyListTag).mainAxisSize(300.dp)) {
+                LazyColumnOrRow(Modifier.fillMaxSize()) {
+                    items(50) {
+                        DisposableEffect(NeverEqualObject) {
+                            composed = true
+                            // Signal when everything is done composing
+                            latch.countDown()
+                            onDispose {
+                                disposed = true
+                            }
+                        }
+
+                        // There will be 10 of these in the 300dp box
+                        Box(Modifier.mainAxisSize(31.dp).focusable()) {
+                            BasicText(it.toString())
+                        }
+                    }
+                }
+            }
+        }
+
+        latch.await()
+        composed = false
+
+        assertWithMessage("Compositions were disposed before we did any scrolling")
+            .that(disposed).isFalse()
+
+        // Mostly a validity check, this is not part of the behavior under test
+        assertWithMessage("Additional composition occurred for no apparent reason")
+            .that(composed).isFalse()
+
+        Thread.sleep(5000L)
+        rule.keyPress(
+            if (vertical) NativeKeyEvent.KEYCODE_DPAD_DOWN else NativeKeyEvent.KEYCODE_DPAD_RIGHT,
+            13
+        )
+        Thread.sleep(5000L)
+
+        rule.waitForIdle()
+
+        assertWithMessage("No additional items were composed after scroll, scroll didn't work")
+            .that(composed).isTrue()
+
+        // We may need to modify this test once we prefetch/cache items outside the viewport
+        assertWithMessage(
+            "No compositions were disposed after scrolling, compositions were leaked"
+        ).that(disposed).isTrue()
+    }
+
+    @Test
+    fun whenItemsAreInitiallyCreatedWith0SizeWeCanScrollWhenTheyExpanded() {
+        val thirdTag = "third"
+        val items = (1..3).toList()
+        var thirdHasSize by mutableStateOf(false)
+
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.fillMaxCrossAxis()
+                    .mainAxisSize(100.dp)
+                    .testTag(LazyListTag)
+            ) {
+                items(items) {
+                    if (it == 3) {
+                        Box(
+                            Modifier.testTag(thirdTag)
+                                .then(fillParentMaxCrossAxis())
+                                .mainAxisSize(if (thirdHasSize) 60.dp else 0.dp).focusable()
+                        )
+                    } else {
+                        Box(Modifier.then(fillParentMaxCrossAxis()).mainAxisSize(60.dp).focusable())
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag(thirdTag)
+            .assertExists()
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            thirdHasSize = true
+        }
+
+        rule.waitForIdle()
+
+        rule.keyPress(2)
+
+        rule.onNodeWithTag(thirdTag)
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun itemFillingParentWidth() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+                items(listOf(0)) {
+                    Spacer(
+                        Modifier.fillParentMaxWidth().requiredHeight(50.dp).testTag(firstItemTag)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(100.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun itemFillingParentHeight() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+                items(listOf(0)) {
+                    Spacer(
+                        Modifier.requiredWidth(50.dp).fillParentMaxHeight().testTag(firstItemTag)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(150.dp)
+    }
+
+    @Test
+    fun itemFillingParentSize() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+                items(listOf(0)) {
+                    Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(100.dp)
+            .assertHeightIsEqualTo(150.dp)
+    }
+
+    @Test
+    fun itemFillingParentWidthFraction() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+                items(listOf(0)) {
+                    Spacer(
+                        Modifier.fillParentMaxWidth(0.7f)
+                            .requiredHeight(50.dp)
+                            .testTag(firstItemTag)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(70.dp)
+            .assertHeightIsEqualTo(50.dp)
+    }
+
+    @Test
+    fun itemFillingParentHeightFraction() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+                items(listOf(0)) {
+                    Spacer(
+                        Modifier.requiredWidth(50.dp)
+                            .fillParentMaxHeight(0.3f)
+                            .testTag(firstItemTag)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(45.dp)
+    }
+
+    @Test
+    fun itemFillingParentSizeFraction() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+                items(listOf(0)) {
+                    Spacer(Modifier.fillParentMaxSize(0.5f).testTag(firstItemTag))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(50.dp)
+            .assertHeightIsEqualTo(75.dp)
+    }
+
+    @Test
+    fun itemFillingParentSizeParentResized() {
+        var parentSize by mutableStateOf(100.dp)
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(parentSize)) {
+                items(listOf(0)) {
+                    Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            parentSize = 150.dp
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertWidthIsEqualTo(150.dp)
+            .assertHeightIsEqualTo(150.dp)
+    }
+
+    @Test
+    fun whenNotAnymoreAvailableItemWasDisplayed() {
+        var items by mutableStateOf((1..30).toList())
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+                items(items) {
+                    Box(Modifier.requiredSize(20.dp).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // after scroll we will display items 16-20
+        rule.keyPress(17)
+
+        rule.runOnIdle {
+            items = (1..10).toList()
+        }
+
+        // there is no item 16 anymore so we will just display the last items 6-10
+        rule.onNodeWithTag("6")
+            .assertStartPositionIsAlmost(0.dp)
+    }
+
+    @Test
+    fun whenFewDisplayedItemsWereRemoved() {
+        var items by mutableStateOf((1..10).toList())
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // after scroll we will display items 6-10
+        rule.keyPress(5)
+        rule.runOnIdle {
+            items = (1..8).toList()
+        }
+
+        // there are no more items 9 and 10, so we have to scroll back
+        rule.onNodeWithTag("4")
+            .assertStartPositionIsAlmost(0.dp)
+    }
+
+    @Test
+    fun whenItemsBecameEmpty() {
+        var items by mutableStateOf((1..10).toList())
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSizeIn(maxHeight = 100.dp, maxWidth = 100.dp)
+                    .testTag(LazyListTag)
+            ) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // after scroll we will display items 2-6
+        rule.keyPress(2)
+
+        rule.runOnIdle {
+            items = emptyList()
+        }
+
+        // there are no more items so the lazy list is zero sized
+        rule.onNodeWithTag(LazyListTag)
+            .assertWidthIsEqualTo(0.dp)
+            .assertHeightIsEqualTo(0.dp)
+
+        // and has no children
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+        rule.onNodeWithTag("2")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun scrollBackAndForth() {
+        val items by mutableStateOf((1..20).toList())
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)
+            ) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        // after scroll we will display items 6-10
+        rule.keyPress(5)
+
+        // and scroll back
+        rule.keyPress(5, reverseScroll = true)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionIsAlmost(0.dp)
+    }
+
+    @Test
+    fun tryToScrollBackwardWhenAlreadyOnTop() {
+        val items by mutableStateOf((1..20).toList())
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+                items(items) {
+                    Box(Modifier.requiredSize(20.dp).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // getting focus to the first element
+        rule.keyPress(2)
+        // we already displaying the first item, so this should do nothing
+        rule.keyPress(4, reverseScroll = true)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionIsAlmost(0.dp)
+        rule.onNodeWithTag("2")
+            .assertStartPositionIsAlmost(20.dp)
+        rule.onNodeWithTag("3")
+            .assertStartPositionIsAlmost(40.dp)
+        rule.onNodeWithTag("4")
+            .assertStartPositionIsAlmost(60.dp)
+        rule.onNodeWithTag("5")
+            .assertStartPositionIsAlmost(80.dp)
+    }
+
+    @Test
+    fun contentOfNotStableItemsIsNotRecomposedDuringScroll() {
+        val items = listOf(NotStable(1), NotStable(2))
+        var firstItemRecomposed = 0
+        var secondItemRecomposed = 0
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+                items(items) {
+                    if (it.count == 1) {
+                        firstItemRecomposed++
+                    } else {
+                        secondItemRecomposed++
+                    }
+                    Box(Modifier.requiredSize(75.dp).focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(firstItemRecomposed).isEqualTo(1)
+            assertThat(secondItemRecomposed).isEqualTo(1)
+        }
+
+        rule.keyPress(2)
+
+        rule.runOnIdle {
+            assertThat(firstItemRecomposed).isEqualTo(1)
+            assertThat(secondItemRecomposed).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun onlyOneMeasurePassForScrollEvent() {
+        val items by mutableStateOf((1..20).toList())
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyListState()
+            state.prefetchingEnabled = false
+            LazyColumnOrRow(
+                Modifier.requiredSize(100.dp).testTag(LazyListTag),
+                state = state
+            ) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        val initialMeasurePasses = state.numMeasurePasses
+
+        rule.runOnIdle {
+            with(rule.density) {
+                state.onScroll(-110.dp.toPx())
+            }
+        }
+
+        rule.waitForIdle()
+
+        assertThat(state.numMeasurePasses).isEqualTo(initialMeasurePasses + 1)
+    }
+
+    @Test
+    fun onlyOneInitialMeasurePass() {
+        val items by mutableStateOf((1..20).toList())
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            LazyColumnOrRow(
+                Modifier.requiredSize(100.dp).testTag(LazyListTag),
+                state = state
+            ) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.numMeasurePasses).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun scroll_makeListSmaller_scroll() {
+        var items by mutableStateOf((1..100).toList())
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+                items(items) {
+                    Box(Modifier.requiredSize(10.dp).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(30)
+        rule.runOnIdle {
+            items = (1..11).toList()
+        }
+
+        rule.waitForIdle()
+        // try to scroll after the data set has been updated. this was causing a crash previously
+        rule.keyPress(1, reverseScroll = true)
+        rule.onNodeWithTag("11")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun initialScrollIsApplied() {
+        val items by mutableStateOf((0..20).toList())
+        lateinit var state: TvLazyListState
+        val expectedOffset = with(rule.density) { 10.dp.roundToPx() }
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyListState(2, expectedOffset)
+            LazyColumnOrRow(
+                Modifier.requiredSize(100.dp).testTag(LazyListTag),
+                state = state
+            ) {
+                items(items) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
+        }
+
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo((-10).dp)
+    }
+
+    @Test
+    fun stateIsRestored() {
+        val restorationTester = StateRestorationTester(rule)
+        var state: TvLazyListState? = null
+        restorationTester.setContent {
+            state = rememberLazyListState()
+            LazyColumnOrRow(
+                Modifier.requiredSize(100.dp).testTag(LazyListTag),
+                state = state!!
+            ) {
+                items(20) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        val (index, scrollOffset) = rule.runOnIdle {
+            state!!.firstVisibleItemIndex to state!!.firstVisibleItemScrollOffset
+        }
+
+        state = null
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(state!!.firstVisibleItemIndex).isEqualTo(index)
+            assertThat(state!!.firstVisibleItemScrollOffset).isEqualTo(scrollOffset)
+        }
+    }
+
+    @Test
+    fun snapToItemIndex() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyListState()
+            LazyColumnOrRow(
+                Modifier.requiredSize(100.dp).testTag(LazyListTag),
+                state = state
+            ) {
+                items(20) {
+                    Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(3, 10)
+            }
+            assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+        }
+    }
+
+    // TODO: Needs to be debugged and fixed for TV surfaces.
+    /*@Test
+    fun itemsAreNotRedrawnDuringScroll() {
+        val items = (0..20).toList()
+        val redrawCount = Array(6) { 0 }
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(100.dp).testTag(LazyListTag),
+                pivotOffsetConfig = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(items) {
+                    Box(
+                        Modifier.requiredSize(20.dp)
+                            .testTag(it.toString())
+                            .drawBehind {
+                                redrawCount[it]++
+                                if (redrawCount[it] != 1) {
+                                    Log.i("REMOVE_ME", Exception("Redrawn").stackTraceToString())
+                                }
+                            }
+                            .focusable()
+                    ) {
+                        BasicText(it.toString())
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(3)
+        rule.onNodeWithTag("0").assertIsNotDisplayed()
+        rule.runOnIdle {
+            redrawCount.forEachIndexed { index, i ->
+                assertWithMessage("Item with index $index was redrawn $i times")
+                    .that(i).isEqualTo(1)
+            }
+        }
+    }*/
+
+    @Test
+    fun itemInvalidationIsNotCausingAnotherItemToRedraw() {
+        val redrawCount = Array(2) { 0 }
+        var stateUsedInDrawScope by mutableStateOf(false)
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+                items(2) {
+                    Spacer(
+                        Modifier.requiredSize(50.dp)
+                            .drawBehind {
+                                redrawCount[it]++
+                                if (it == 1) {
+                                    stateUsedInDrawScope.hashCode()
+                                }
+                            }
+                    )
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            stateUsedInDrawScope = true
+        }
+
+        rule.runOnIdle {
+            assertWithMessage("First items is not expected to be redrawn")
+                .that(redrawCount[0]).isEqualTo(1)
+            assertWithMessage("Second items is expected to be redrawn")
+                .that(redrawCount[1]).isEqualTo(2)
+        }
+    }
+
+    @Test
+    fun notVisibleAnymoreItemNotAffectingCrossAxisSize() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        val itemSizeMinusOne = with(rule.density) { 29.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSizeMinusOne).testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it }
+            ) {
+                items(2) {
+                    Spacer(
+                        if (it == 0) {
+                            Modifier.crossAxisSize(30.dp).mainAxisSize(itemSizeMinusOne)
+                        } else {
+                            Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize)
+                        }
+                    )
+                }
+            }
+        }
+
+        state.scrollBy(itemSize)
+
+        rule.onNodeWithTag(LazyListTag)
+            .assertCrossAxisSizeIsEqualTo(20.dp)
+    }
+
+    @Test
+    fun itemStillVisibleAfterOverscrollIsAffectingCrossAxisSize() {
+        val items = (0..2).toList()
+        val itemSize = with(rule.density) { 30.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSize * 1.75f).testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it }
+            ) {
+                items(items) {
+                    Spacer(
+                        if (it == 0) {
+                            Modifier.crossAxisSize(30.dp).mainAxisSize(itemSize / 2)
+                        } else if (it == 1) {
+                            Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize / 2)
+                        } else {
+                            Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize)
+                        }
+                    )
+                }
+            }
+        }
+
+        state.scrollBy(itemSize)
+
+        rule.onNodeWithTag(LazyListTag)
+            .assertCrossAxisSizeIsEqualTo(30.dp)
+    }
+
+    @Test
+    fun usedWithArray() {
+        val items = arrayOf("1", "2", "3")
+
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow {
+                items(items) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2)
+    }
+
+    @Test
+    fun usedWithArrayIndexed() {
+        val items = arrayOf("1", "2", "3")
+
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow {
+                itemsIndexed(items) { index, item ->
+                    Spacer(Modifier.requiredSize(itemSize).testTag("$index*$item"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0*1")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1*2")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2*3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2)
+    }
+
+    @Test
+    fun changeItemsCountAndScrollImmediately() {
+        lateinit var state: TvLazyListState
+        var count by mutableStateOf(100)
+        val composedIndexes = mutableListOf<Int>()
+        rule.setContent {
+            state = rememberLazyListState()
+            LazyColumnOrRow(Modifier.fillMaxCrossAxis().mainAxisSize(10.dp), state) {
+                items(count) { index ->
+                    composedIndexes.add(index)
+                    Box(Modifier.size(20.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            composedIndexes.clear()
+            count = 10
+            runBlocking(AutoTestFrameClock()) {
+                state.scrollToItem(50)
+            }
+            composedIndexes.forEach {
+                assertThat(it).isLessThan(count)
+            }
+            assertThat(state.firstVisibleItemIndex).isEqualTo(9)
+        }
+    }
+
+    @Test
+    fun overscrollingBackwardFromNotTheFirstPosition() {
+        val containerTag = "container"
+        val itemSizePx = 10
+        val itemSizeDp = with(rule.density) { itemSizePx.toDp() }
+        val containerSize = itemSizeDp * 5
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier
+                    .testTag(containerTag)
+                    .size(containerSize)
+            ) {
+                LazyColumnOrRow(
+                    Modifier
+                        .testTag(LazyListTag)
+                        .background(Color.Blue),
+                    state = rememberLazyListState(2, 5)
+                ) {
+                    items(100) {
+                        Box(
+                            Modifier
+                                .fillMaxCrossAxis()
+                                .mainAxisSize(itemSizeDp)
+                                .testTag("$it")
+                                .focusable()
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(
+            if (vertical) NativeKeyEvent.KEYCODE_DPAD_UP else NativeKeyEvent.KEYCODE_DPAD_LEFT,
+            15
+        )
+
+        rule.onNodeWithTag(LazyListTag)
+            .assertMainAxisSizeIsEqualTo(containerSize)
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("4")
+            .assertStartPositionInRootIsEqualTo(containerSize - itemSizeDp)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun doesNotClipHorizontalOverdraw() {
+        rule.setContent {
+            Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) {
+                LazyColumnOrRow(
+                    Modifier
+                        .padding(20.dp)
+                        .fillMaxSize(),
+                    rememberLazyListState(1)
+                ) {
+                    items(4) {
+                        Box(Modifier.size(20.dp).drawOutsideOfBounds())
+                    }
+                }
+            }
+        }
+
+        val horizontalPadding = if (vertical) 0.dp else 20.dp
+        val verticalPadding = if (vertical) 20.dp else 0.dp
+
+        rule.onNodeWithTag("container")
+            .captureToImage()
+            .assertShape(
+                density = rule.density,
+                shape = RectangleShape,
+                shapeColor = Color.Red,
+                backgroundColor = Color.Gray,
+                horizontalPadding = horizontalPadding,
+                verticalPadding = verticalPadding
+            )
+    }
+
+    @Test
+    fun initialScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
+        lateinit var state: TvLazyListState
+        var itemsCount by mutableStateOf(0)
+        rule.setContent {
+            state = rememberLazyListState(2, 10)
+            LazyColumnOrRow(Modifier.fillMaxSize(), state) {
+                items(itemsCount) {
+                    Box(Modifier.size(20.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            itemsCount = 100
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+        }
+    }
+
+    @Test
+    fun restoredScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
+        lateinit var state: TvLazyListState
+        var itemsCount = 100
+        val recomposeCounter = mutableStateOf(0)
+        val tester = StateRestorationTester(rule)
+        tester.setContent {
+            state = rememberLazyListState()
+            LazyColumnOrRow(Modifier.fillMaxSize(), state) {
+                recomposeCounter.value
+                items(itemsCount) {
+                    Box(Modifier.size(20.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(2, 10)
+            }
+            itemsCount = 0
+        }
+
+        tester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            itemsCount = 100
+            recomposeCounter.value = 1
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+        }
+    }
+
+    @Test
+    fun animateScrollToItemDoesNotScrollPastItem() {
+        lateinit var state: TvLazyListState
+        var target = 0
+        var reverse = false
+        rule.setContent {
+            val listState = rememberLazyListState()
+            SideEffect {
+                state = listState
+            }
+            LazyColumnOrRow(Modifier.fillMaxSize(), listState) {
+                items(2500) { _ ->
+                    Box(Modifier.size(100.dp))
+                }
+            }
+
+            if (reverse) {
+                assertThat(listState.firstVisibleItemIndex).isAtLeast(target)
+            } else {
+                assertThat(listState.firstVisibleItemIndex).isAtMost(target)
+            }
+        }
+
+        // Try a bunch of different targets with varying spacing
+        listOf(500, 800, 1500, 1600, 1800).forEach {
+            target = it
+            rule.runOnIdle {
+                runBlocking(AutoTestFrameClock()) {
+                    state.animateScrollToItem(target)
+                }
+            }
+
+            rule.runOnIdle {
+                assertThat(state.firstVisibleItemIndex).isEqualTo(target)
+                assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            }
+        }
+
+        reverse = true
+
+        listOf(1600, 1500, 800, 500, 0).forEach {
+            target = it
+            rule.runOnIdle {
+                runBlocking(AutoTestFrameClock()) {
+                    state.animateScrollToItem(target)
+                }
+            }
+
+            rule.runOnIdle {
+                assertThat(state.firstVisibleItemIndex).isEqualTo(target)
+                assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            }
+        }
+    }
+
+    @Test
+    fun animateScrollToTheLastItemWhenItemsAreLargerThenTheScreen() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            LazyColumnOrRow(Modifier.crossAxisSize(150.dp).mainAxisSize(100.dp), state) {
+                items(20) {
+                    Box(Modifier.size(150.dp))
+                }
+            }
+        }
+
+        // Try a bunch of different start indexes
+        listOf(0, 5, 12).forEach {
+            val startIndex = it
+            rule.runOnIdle {
+                runBlocking(AutoTestFrameClock()) {
+                    state.scrollToItem(startIndex)
+                    state.animateScrollToItem(19)
+                }
+            }
+
+            rule.runOnIdle {
+                assertThat(state.firstVisibleItemIndex).isEqualTo(19)
+                assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            }
+        }
+    }
+
+    @Test
+    fun recreatingContentLambdaTriggersItemRecomposition() {
+        val countState = mutableStateOf(0)
+        rule.setContent {
+            val count = countState.value
+            LazyColumnOrRow {
+                item {
+                    BasicText(text = "Count $count")
+                }
+            }
+        }
+
+        rule.onNodeWithText("Count 0")
+            .assertIsDisplayed()
+
+        rule.runOnIdle {
+            countState.value++
+        }
+
+        rule.onNodeWithText("Count 1")
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun semanticsScroll_isAnimated() {
+        rule.mainClock.autoAdvance = false
+        val state = TvLazyListState()
+
+        rule.setContent {
+            LazyColumnOrRow(Modifier.testTag(LazyListTag), state = state) {
+                items(50) {
+                    Box(Modifier.mainAxisSize(200.dp))
+                }
+            }
+        }
+
+        rule.waitForIdle()
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+
+        rule.onNodeWithTag(LazyListTag).performSemanticsAction(SemanticsActions.ScrollBy) {
+            if (vertical) {
+                it(0f, 100f)
+            } else {
+                it(100f, 0f)
+            }
+        }
+
+        // We haven't advanced time yet, make sure it's still zero
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+
+        // Advance and make sure we're partway through
+        // Note that we need two frames for the animation to actually happen
+        rule.mainClock.advanceTimeByFrame()
+        rule.mainClock.advanceTimeByFrame()
+
+        // The items are 200dp each, so still the first one, but offset
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+        assertThat(state.firstVisibleItemScrollOffset).isLessThan(100)
+
+        // Finish the scroll, make sure we're at the target
+        rule.mainClock.advanceTimeBy(5000)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(100)
+    }
+
+    @Test
+    fun maxIntElements() {
+        val itemSize = with(rule.density) { 15.toDp() }
+
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(itemSize * 3),
+                state = TvLazyListState(firstVisibleItemIndex = Int.MAX_VALUE - 3)
+            ) {
+                items(Int.MAX_VALUE) {
+                    Box(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("${Int.MAX_VALUE - 3}").assertStartPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("${Int.MAX_VALUE - 2}").assertStartPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("${Int.MAX_VALUE - 1}").assertStartPositionInRootIsEqualTo(itemSize * 2)
+
+        rule.onNodeWithTag("${Int.MAX_VALUE}").assertDoesNotExist()
+        rule.onNodeWithTag("0").assertDoesNotExist()
+    }
+
+    @Test
+    fun scrollingByExactlyTheItemSize_switchesTheFirstVisibleItem() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSize * 3),
+                state = rememberLazyListState().also { state = it },
+            ) {
+                items(5) {
+                    Spacer(
+                        Modifier.size(itemSize).testTag("$it")
+                    )
+                }
+            }
+        }
+
+        state.scrollBy(itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun pointerInputScrollingIsAllowedWhenUserScrollingIsEnabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSize * 3).testTag(LazyListTag),
+                userScrollEnabled = true,
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(3)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun pointerInputScrollingIsDisallowedWhenUserScrollingIsDisabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSize * 3).testTag(LazyListTag),
+                userScrollEnabled = false,
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.keyPress(1)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun programmaticScrollingIsAllowedWhenUserScrollingIsDisabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSize * 3),
+                state = rememberLazyListState().also { state = it },
+                userScrollEnabled = false,
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun semanticScrollingIsDisallowedWhenUserScrollingIsDisabled() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.mainAxisSize(itemSize * 3).testTag(LazyListTag),
+                userScrollEnabled = false,
+            ) {
+                items(5) {
+                    Spacer(Modifier.size(itemSize).testTag("$it"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyListTag)
+            .assert(keyNotDefined(SemanticsActions.ScrollBy))
+            .assert(keyNotDefined(SemanticsActions.ScrollToIndex))
+            // but we still have a read only scroll range property
+            .assert(
+                keyIsDefined(
+                    if (vertical) {
+                        SemanticsProperties.VerticalScrollAxisRange
+                    } else {
+                        SemanticsProperties.HorizontalScrollAxisRange
+                    }
+                )
+            )
+    }
+
+    @Test
+    fun withMissingItems() {
+        val itemSize = with(rule.density) { 30.toDp() }
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            LazyColumnOrRow(
+                modifier = Modifier.mainAxisSize(itemSize + 1.dp),
+                state = state
+            ) {
+                items(4) {
+                    if (it != 1) {
+                        Box(Modifier.size(itemSize).testTag(it.toString()).focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0").assertIsDisplayed()
+        rule.onNodeWithTag("2").assertIsDisplayed()
+
+        rule.runOnIdle {
+            runBlocking {
+                state.scrollToItem(1)
+            }
+        }
+
+        rule.onNodeWithTag("0").assertIsNotDisplayed()
+        rule.onNodeWithTag("2").assertIsDisplayed()
+        rule.onNodeWithTag("3").assertIsDisplayed()
+    }
+
+    @Test
+    fun recomposingWithNewComposedModifierObjectIsNotCausingRemeasure() {
+        var remeasureCount = 0
+        val layoutModifier = Modifier.layout { measurable, constraints ->
+            remeasureCount++
+            val placeable = measurable.measure(constraints)
+            layout(placeable.width, placeable.height) {
+                placeable.place(0, 0)
+            }
+        }
+        val counter = mutableStateOf(0)
+
+        rule.setContentWithTestViewConfiguration {
+            counter.value // just to trigger recomposition
+            LazyColumnOrRow(
+                // this will return a new object everytime causing Lazy list recomposition
+                // without causing remeasure
+                Modifier.composed { layoutModifier }
+            ) {
+                items(1) {
+                    Spacer(Modifier.size(10.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(remeasureCount).isEqualTo(1)
+            counter.value++
+        }
+
+        rule.runOnIdle {
+            assertThat(remeasureCount).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun passingNegativeItemsCountIsNotAllowed() {
+        var exception: Exception? = null
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow {
+                try {
+                    items(-1) {
+                        Box(Modifier)
+                    }
+                } catch (e: Exception) {
+                    exception = e
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(exception).isInstanceOf(IllegalArgumentException::class.java)
+        }
+    }
+
+    @Test
+    fun scrollingALotDoesntCauseLazyLayoutRecomposition() {
+        var recomposeCount = 0
+        lateinit var state: TvLazyListState
+
+        rule.setContentWithTestViewConfiguration {
+            state = rememberLazyListState()
+            LazyColumnOrRow(
+                Modifier.composed {
+                    recomposeCount++
+                    Modifier
+                },
+                state
+            ) {
+                items(1000) {
+                    Spacer(Modifier.size(10.dp))
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(recomposeCount).isEqualTo(1)
+
+            runBlocking {
+                state.scrollToItem(100)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(recomposeCount).isEqualTo(1)
+        }
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    fun zIndexOnItemAffectsDrawingOrder() {
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                Modifier.size(6.dp).testTag(LazyListTag)
+            ) {
+                items(listOf(Color.Blue, Color.Green, Color.Red)) { color ->
+                    Box(
+                        Modifier
+                            .mainAxisSize(2.dp)
+                            .crossAxisSize(6.dp)
+                            .zIndex(if (color == Color.Green) 1f else 0f)
+                            .drawBehind {
+                                drawRect(
+                                    color,
+                                    topLeft = Offset(-10.dp.toPx(), -10.dp.toPx()),
+                                    size = Size(20.dp.toPx(), 20.dp.toPx())
+                                )
+                            })
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyListTag)
+            .captureToImage()
+            .assertPixels { Color.Green }
+    }
+
+    // ********************* END OF TESTS *********************
+    // Helper functions, etc. live below here
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
+    }
+}
+
+internal val NeverEqualObject = object {
+    override fun equals(other: Any?): Boolean {
+        return false
+    }
+}
+
+private data class NotStable(val count: Int)
+
+internal const val TestTouchSlop = 18f
+
+internal fun IntegerSubject.isWithin1PixelFrom(expected: Int) {
+    isEqualTo(expected, 1)
+}
+
+internal fun IntegerSubject.isEqualTo(expected: Int, tolerance: Int) {
+    isIn(Range.closed(expected - tolerance, expected + tolerance))
+}
+
+internal fun ComposeContentTestRule.setContentWithTestViewConfiguration(
+    composable: @Composable () -> Unit
+) {
+    this.setContent {
+        WithTouchSlop(TestTouchSlop, composable)
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsContentPaddingTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsContentPaddingTest.kt
new file mode 100644
index 0000000..eccaaed
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsContentPaddingTest.kt
@@ -0,0 +1,781 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@LargeTest
+@RunWith(Parameterized::class)
+class LazyListsContentPaddingTest(orientation: Orientation) :
+    BaseLazyListTestWithOrientation(orientation) {
+
+    private val LazyListTag = "LazyList"
+    private val ItemTag = "item"
+    private val ContainerTag = "container"
+
+    private var itemSize: Dp = Dp.Infinity
+    private var smallPaddingSize: Dp = Dp.Infinity
+    private var itemSizePx = 50f
+    private var smallPaddingSizePx = 12f
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSize = itemSizePx.toDp()
+            smallPaddingSize = smallPaddingSizePx.toDp()
+        }
+    }
+
+    @Test
+    fun contentPaddingIsApplied() {
+        lateinit var state: TvLazyListState
+        val containerSize = itemSize * 2
+        val largePaddingSize = itemSize
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(containerSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it },
+                contentPadding = PaddingValues(
+                    mainAxis = largePaddingSize,
+                    crossAxis = smallPaddingSize
+                )
+            ) {
+                items(listOf(1)) {
+                    Spacer(
+                        Modifier
+                            .then(fillParentMaxCrossAxis())
+                            .mainAxisSize(itemSize)
+                            .testTag(ItemTag)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ItemTag)
+            .assertCrossAxisStartPositionInRootIsEqualTo(smallPaddingSize)
+            .assertStartPositionInRootIsEqualTo(largePaddingSize)
+            .assertCrossAxisSizeIsEqualTo(containerSize - smallPaddingSize * 2)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        state.scrollBy(largePaddingSize)
+
+        rule.onNodeWithTag(ItemTag)
+            .assertStartPositionInRootIsEqualTo(0.dp)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun contentPaddingIsNotAffectingScrollPosition() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(itemSize * 2)
+                    .testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it },
+                contentPadding = PaddingValues(mainAxis = itemSize)
+            ) {
+                items(listOf(1)) {
+                    Spacer(
+                        Modifier
+                            .then(fillParentMaxCrossAxis())
+                            .mainAxisSize(itemSize)
+                            .testTag(ItemTag))
+                }
+            }
+        }
+
+        state.assertScrollPosition(0, 0.dp)
+
+        state.scrollBy(itemSize)
+
+        state.assertScrollPosition(0, itemSize)
+    }
+
+    @Test
+    fun scrollForwardItemWithinStartPaddingDisplayed() {
+        lateinit var state: TvLazyListState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(padding * 2 + itemSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it },
+                contentPadding = PaddingValues(mainAxis = padding)
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(padding)
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize + padding)
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2 + padding)
+
+        state.scrollBy(padding)
+
+        state.assertScrollPosition(1, padding - itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2)
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 3)
+    }
+
+    @Test
+    fun scrollBackwardItemWithinStartPaddingDisplayed() {
+        lateinit var state: TvLazyListState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(itemSize + padding * 2)
+                    .testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it },
+                contentPadding = PaddingValues(mainAxis = padding)
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+        state.scrollBy(-itemSize * 1.5f)
+
+        state.assertScrollPosition(1, itemSize * 0.5f)
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(itemSize * 1.5f - padding)
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2.5f - padding)
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize * 3.5f - padding)
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 4.5f - padding)
+    }
+
+    @Test
+    fun scrollForwardTillTheEnd() {
+        lateinit var state: TvLazyListState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(padding * 2 + itemSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it },
+                contentPadding = PaddingValues(mainAxis = padding)
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+
+        state.assertScrollPosition(3, 0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize - padding)
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2 - padding)
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 3 - padding)
+
+        // there are no space to scroll anymore, so it should change nothing
+        state.scrollBy(10.dp)
+
+        state.assertScrollPosition(3, 0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize - padding)
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2 - padding)
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 3 - padding)
+    }
+
+    @Test
+    fun scrollForwardTillTheEndAndABitBack() {
+        lateinit var state: TvLazyListState
+        val padding = itemSize * 1.5f
+        rule.setContent {
+            LazyColumnOrRow(
+                modifier = Modifier.requiredSize(padding * 2 + itemSize)
+                    .testTag(LazyListTag),
+                state = rememberLazyListState().also { state = it },
+                contentPadding = PaddingValues(mainAxis = padding)
+            ) {
+                items((0..3).toList()) {
+                    Spacer(Modifier.requiredSize(itemSize).testTag(it.toString()))
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+        state.scrollBy(-itemSize / 2)
+
+        state.assertScrollPosition(2, itemSize / 2)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize * 1.5f - padding)
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize * 2.5f - padding)
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize * 3.5f - padding)
+    }
+
+    @Test
+    fun contentPaddingAndWrapContent() {
+        rule.setContent {
+            Box(modifier = Modifier.testTag(ContainerTag)) {
+                LazyColumnOrRow(
+                    contentPadding = PaddingValues(
+                        beforeContentCrossAxis = 2.dp,
+                        beforeContent = 4.dp,
+                        afterContentCrossAxis = 6.dp,
+                        afterContent = 8.dp
+                    )
+                ) {
+                    items(listOf(1)) {
+                        Spacer(Modifier.requiredSize(itemSize).testTag(ItemTag))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ItemTag)
+            .assertCrossAxisStartPositionInRootIsEqualTo(2.dp)
+            .assertStartPositionInRootIsEqualTo(4.dp)
+            .assertCrossAxisSizeIsEqualTo(itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(itemSize + 2.dp + 6.dp)
+            .assertMainAxisSizeIsEqualTo(itemSize + 4.dp + 8.dp)
+    }
+
+    @Test
+    fun contentPaddingAndNoContent() {
+        rule.setContent {
+            Box(modifier = Modifier.testTag(ContainerTag)) {
+                LazyColumnOrRow(
+                    contentPadding = PaddingValues(
+                        beforeContentCrossAxis = 2.dp,
+                        beforeContent = 4.dp,
+                        afterContentCrossAxis = 6.dp,
+                        afterContent = 8.dp
+                    )
+                ) { }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(8.dp)
+            .assertMainAxisSizeIsEqualTo(12.dp)
+    }
+
+    @Test
+    fun contentPaddingAndZeroSizedItem() {
+        rule.setContent {
+            Box(modifier = Modifier.testTag(ContainerTag)) {
+                LazyColumnOrRow(
+                    contentPadding = PaddingValues(
+                        beforeContentCrossAxis = 2.dp,
+                        beforeContent = 4.dp,
+                        afterContentCrossAxis = 6.dp,
+                        afterContent = 8.dp
+                    )
+                ) {
+                    items(0) { }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertCrossAxisStartPositionInRootIsEqualTo(0.dp)
+            .assertStartPositionInRootIsEqualTo(0.dp)
+            .assertCrossAxisSizeIsEqualTo(8.dp)
+            .assertMainAxisSizeIsEqualTo(12.dp)
+    }
+
+    @Test
+    fun contentPaddingAndReverseLayout() {
+        val topPadding = itemSize * 2
+        val bottomPadding = itemSize / 2
+        val listSize = itemSize * 3
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            LazyColumnOrRow(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(listSize),
+                contentPadding = PaddingValues(
+                    beforeContent = topPadding,
+                    afterContent = bottomPadding
+                ),
+            ) {
+                items(3) { index ->
+                    Box(Modifier.requiredSize(itemSize).testTag("$index"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(listSize - bottomPadding - itemSize)
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(listSize - bottomPadding - itemSize * 2)
+        // Partially visible.
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(-itemSize / 2)
+
+        // Scroll to the top.
+        state.scrollBy(itemSize * 2.5f)
+
+        rule.onNodeWithTag("2").assertStartPositionInRootIsEqualTo(topPadding)
+        // Shouldn't be visible
+        rule.onNodeWithTag("1").assertIsNotDisplayed()
+        rule.onNodeWithTag("0").assertIsNotDisplayed()
+    }
+
+    @Test
+    fun overscrollWithContentPadding() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize + smallPaddingSize * 2)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = smallPaddingSize)
+                ) {
+                    items(2) {
+                        Box(Modifier.testTag("$it").fillParentMaxSize())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(smallPaddingSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(smallPaddingSize + itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            runBlocking {
+                // itemSizePx is the maximum offset, plus if we overscroll the content padding
+                // the layout mechanism will decide the item 0 is not needed until we start
+                // filling the over scrolled gap.
+                state.scrollBy(value = itemSizePx + smallPaddingSizePx * 1.5f)
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(smallPaddingSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(smallPaddingSize - itemSize)
+            .assertMainAxisSizeIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_initialState() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(0, 0.dp)
+            state.assertVisibleItems(0 to 0.dp)
+            state.assertLayoutInfoOffsetRange(-itemSize, itemSize * 0.5f)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollByPadding() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize)
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(1, 0.dp)
+            state.assertVisibleItems(0 to -itemSize, 1 to 0.dp)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollToLastItem() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollTo(3)
+
+        rule.onNodeWithTag("1")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollToLastItemByDelta() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+
+        rule.onNodeWithTag("1")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun totalPaddingLargerParentSize_scrollTillTheEnd() {
+        // the whole end content padding is displayed
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 4.5f)
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("3")
+            .assertStartPositionInRootIsEqualTo(-itemSize * 0.5f)
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, itemSize * 1.5f)
+            state.assertVisibleItems(3 to -itemSize * 1.5f)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_initialState() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(0, 0.dp)
+            state.assertVisibleItems(0 to 0.dp)
+            state.assertLayoutInfoOffsetRange(-itemSize * 2, -itemSize * 0.5f)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollByPadding() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 2)
+
+        rule.onNodeWithTag("0")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("2")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(2, 0.dp)
+            state.assertVisibleItems(0 to -itemSize * 2, 1 to -itemSize, 2 to 0.dp)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollToLastItem() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollTo(3)
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(1 to -itemSize * 2, 2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollToLastItemByDelta() {
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(itemSize * 3)
+
+        rule.onNodeWithTag("0")
+            .assertIsNotDisplayed()
+
+        rule.onNodeWithTag("1")
+            .assertStartPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithTag("2")
+            .assertStartPositionInRootIsEqualTo(itemSize)
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, 0.dp)
+            state.assertVisibleItems(1 to -itemSize * 2, 2 to -itemSize, 3 to 0.dp)
+        }
+    }
+
+    @Test
+    fun eachPaddingLargerParentSize_scrollTillTheEnd() {
+        // only the end content padding is displayed
+        lateinit var state: TvLazyListState
+        rule.setContent {
+            state = rememberLazyListState()
+            Box(modifier = Modifier.testTag(ContainerTag).size(itemSize * 1.5f)) {
+                LazyColumnOrRow(
+                    state = state,
+                    contentPadding = PaddingValues(mainAxis = itemSize * 2)
+                ) {
+                    items(4) {
+                        Box(Modifier.testTag("$it").size(itemSize))
+                    }
+                }
+            }
+        }
+
+        state.scrollBy(
+            itemSize * 1.5f + // container size
+                itemSize * 2 + // start padding
+                itemSize * 3 // all items
+        )
+
+        rule.onNodeWithTag("3")
+            .assertIsNotDisplayed()
+
+        rule.runOnIdle {
+            state.assertScrollPosition(3, itemSize * 3.5f)
+            state.assertVisibleItems(3 to -itemSize * 3.5f)
+        }
+    }
+
+    private fun TvLazyListState.assertScrollPosition(index: Int, offset: Dp) = with(rule.density) {
+        assertThat(firstVisibleItemIndex).isEqualTo(index)
+        assertThat(firstVisibleItemScrollOffset.toDp().value).isWithin(0.5f).of(offset.value)
+    }
+
+    private fun TvLazyListState.assertLayoutInfoOffsetRange(from: Dp, to: Dp) = with(rule.density) {
+        assertThat(layoutInfo.viewportStartOffset to layoutInfo.viewportEndOffset)
+            .isEqualTo(from.roundToPx() to to.roundToPx())
+    }
+
+    private fun TvLazyListState.assertVisibleItems(vararg expected: Pair<Int, Dp>) =
+        with(rule.density) {
+            assertThat(layoutInfo.visibleItemsInfo.map { it.index to it.offset })
+                .isEqualTo(expected.map { it.first to it.second.roundToPx() })
+        }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsIndexedTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsIndexedTest.kt
new file mode 100644
index 0000000..a868e08
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsIndexedTest.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import org.junit.Rule
+import org.junit.Test
+
+class LazyListsIndexedTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun lazyColumnShowsIndexedItems() {
+        val items = (1..4).map { it.toString() }
+
+        rule.setContent {
+            TvLazyColumn(
+                Modifier.height(200.dp),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                itemsIndexed(items) { index, item ->
+                    Spacer(
+                        Modifier.height(101.dp).fillParentMaxWidth()
+                            .testTag("$index-$item").focusable()
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0-1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("1-2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2-3")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("3-4")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun columnWithIndexesComposedWithCorrectIndexAndItem() {
+        val items = (0..1).map { it.toString() }
+
+        rule.setContent {
+            TvLazyColumn(
+                Modifier.height(200.dp),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                itemsIndexed(items) { index, item ->
+                    BasicText(
+                        "${index}x$item", Modifier.fillParentMaxWidth().requiredHeight(100.dp)
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithText("0x0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithText("1x1")
+            .assertTopPositionInRootIsEqualTo(100.dp)
+    }
+
+    @Test
+    fun lazyRowShowsIndexedItems() {
+        val items = (1..4).map { it.toString() }
+
+        rule.setContent {
+            TvLazyRow(
+                Modifier.width(200.dp),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                itemsIndexed(items) { index, item ->
+                    Spacer(
+                        Modifier.width(101.dp).fillParentMaxHeight()
+                            .testTag("$index-$item").focusable()
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithTag("0-1")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("1-2")
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag("2-3")
+            .assertDoesNotExist()
+
+        rule.onNodeWithTag("3-4")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun rowWithIndexesComposedWithCorrectIndexAndItem() {
+        val items = (0..1).map { it.toString() }
+
+        rule.setContent {
+            TvLazyRow(
+                Modifier.width(200.dp),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                itemsIndexed(items) { index, item ->
+                    BasicText(
+                        "${index}x$item",
+                        Modifier.fillParentMaxHeight().requiredWidth(100.dp).focusable()
+                    )
+                }
+            }
+        }
+
+        rule.onNodeWithText("0x0")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+
+        rule.onNodeWithText("1x1")
+            .assertLeftPositionInRootIsEqualTo(100.dp)
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsReverseLayoutTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsReverseLayoutTest.kt
new file mode 100644
index 0000000..1798212
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyListsReverseLayoutTest.kt
@@ -0,0 +1,516 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.grid.keyPress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class LazyListsReverseLayoutTest {
+
+    private val ContainerTag = "ContainerTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var itemSize: Dp = Dp.Infinity
+
+    @Before
+    fun before() {
+        with(rule.density) {
+            itemSize = 50.toDp()
+        }
+    }
+
+    @Test
+    fun column_emitTwoElementsAsOneItem_positionedReversed() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = true,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                    Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun column_emitTwoItems_positionedReversed() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = true,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                }
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun column_initialScrollPositionIs0() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun column_scrollInWrongDirectionDoesNothing() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // we scroll down and as the scrolling is reversed it shouldn't affect anything
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 2)
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun column_scrollForwardHalfWay() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0.3f)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_UP, 3)
+
+        val scrolled = rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+            with(rule.density) { state.firstVisibleItemScrollOffset.toDp() }
+        }
+
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(-itemSize + scrolled)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(scrolled)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize + scrolled)
+    }
+
+    @Test
+    fun column_scrollForwardTillTheEnd() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..3).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // we scroll a bit more than it is possible just to make sure we would stop correctly
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_UP, 6)
+
+        rule.runOnIdle {
+            with(rule.density) {
+                val realOffset = state.firstVisibleItemScrollOffset.toDp() +
+                    itemSize * state.firstVisibleItemIndex
+                assertThat(realOffset).isEqualTo(itemSize * 2)
+            }
+        }
+
+        rule.onNodeWithTag("3")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("2")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun row_emitTwoElementsAsOneItem_positionedReversed() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = true,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                    Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun row_emitTwoItems_positionedReversed() {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = true,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                }
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("1"))
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun row_initialScrollPositionIs0() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun row_scrollInWrongDirectionDoesNothing() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // we scroll down and as the scrolling is reversed it shouldn't affect anything
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 2)
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        }
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun row_scrollForwardHalfWay() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0.3f)
+            ) {
+                items((0..2).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT, 3)
+
+        val scrolled = rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            with(rule.density) { state.firstVisibleItemScrollOffset.toDp() }
+        }
+
+        rule.onNodeWithTag("2")
+            .assertLeftPositionInRootIsEqualTo(-itemSize + scrolled)
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(scrolled)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(itemSize + scrolled)
+    }
+
+    @Test
+    fun row_scrollForwardTillTheEnd() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = true,
+                state = rememberLazyListState().also { state = it },
+                modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items((0..3).toList()) {
+                    Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                }
+            }
+        }
+
+        // we scroll a bit more than it is possible just to make sure we would stop correctly
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT, 6)
+        rule.runOnIdle {
+            with(rule.density) {
+                val realOffset = state.firstVisibleItemScrollOffset.toDp() +
+                    itemSize * state.firstVisibleItemIndex
+                assertThat(realOffset).isEqualTo(itemSize * 2)
+            }
+        }
+
+        rule.onNodeWithTag("3")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("2")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun row_rtl_emitTwoElementsAsOneItem_positionedReversed() {
+        rule.setContentWithTestViewConfiguration {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                TvLazyRow(
+                    reverseLayout = true,
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    item {
+                        Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                        Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun row_rtl_emitTwoItems_positionedReversed() {
+        rule.setContentWithTestViewConfiguration {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                TvLazyRow(
+                    reverseLayout = true,
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    item {
+                        Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                    }
+                    item {
+                        Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+    }
+
+    @Test
+    fun row_rtl_scrollForwardHalfWay() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                TvLazyRow(
+                    reverseLayout = true,
+                    state = rememberLazyListState().also { state = it },
+                    modifier = Modifier.requiredSize(itemSize * 2).testTag(ContainerTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0.3f)
+                ) {
+                    items((0..2).toList()) {
+                        Box(Modifier.requiredSize(itemSize).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 3)
+
+        val scrolled = rule.runOnIdle {
+            assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+            assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+            with(rule.density) { state.firstVisibleItemScrollOffset.toDp() }
+        }
+
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(-scrolled)
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize - scrolled)
+        rule.onNodeWithTag("2")
+            .assertLeftPositionInRootIsEqualTo(itemSize * 2 - scrolled)
+    }
+
+    @Test
+    fun column_whenParameterChanges() {
+        var reverse by mutableStateOf(true)
+        rule.setContentWithTestViewConfiguration {
+            TvLazyColumn(
+                reverseLayout = reverse,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                    Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            reverse = false
+        }
+
+        rule.onNodeWithTag("0")
+            .assertTopPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertTopPositionInRootIsEqualTo(itemSize)
+    }
+
+    @Test
+    fun row_whenParameterChanges() {
+        var reverse by mutableStateOf(true)
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                reverseLayout = reverse,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                item {
+                    Box(Modifier.requiredSize(itemSize).testTag("0").focusable())
+                    Box(Modifier.requiredSize(itemSize).testTag("1").focusable())
+                }
+            }
+        }
+
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+
+        rule.runOnIdle {
+            reverse = false
+        }
+
+        rule.onNodeWithTag("0")
+            .assertLeftPositionInRootIsEqualTo(0.dp)
+        rule.onNodeWithTag("1")
+            .assertLeftPositionInRootIsEqualTo(itemSize)
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyNestedScrollingTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyNestedScrollingTest.kt
new file mode 100644
index 0000000..c79c0f8
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyNestedScrollingTest.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.grid.keyPress
+import com.google.common.truth.Truth
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazyNestedScrollingTest {
+    private val LazyTag = "LazyTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val expectedDragOffset = 20f
+    private val dragOffsetWithTouchSlop = expectedDragOffset + TestTouchSlop
+
+    @Test
+    fun column_nestedScrollingBackwardInitially() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyColumn(
+                    Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Box(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = 100f + TestTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(100f)
+        }
+    }
+
+    @Test
+    fun column_nestedScrollingBackwardOnceWeScrolledForwardPreviously() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyColumn(
+                    modifier = Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Box(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        // scroll forward
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 3)
+
+        // scroll back so we again on 0 position
+        // we scroll one extra dp to prevent rounding issues
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_UP, 2)
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                draggedOffset = 0f
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = dragOffsetWithTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun column_nestedScrollingForwardWhenTheFullContentIsInitiallyVisible() = runBlocking {
+        val items = (1..2).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyColumn(
+                    Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Box(Modifier.requiredSize(40.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = -dragOffsetWithTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun column_nestedScrollingForwardWhenScrolledToTheEnd() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Vertical,
+                    state = scrollable
+                )
+            ) {
+                TvLazyColumn(
+                    modifier = Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Box(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        // scroll till the end
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN, 3)
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                draggedOffset = 0f
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = 0f, y = -dragOffsetWithTouchSlop))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun row_nestedScrollingBackwardInitially() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Horizontal,
+                    state = scrollable
+                )
+            ) {
+                TvLazyRow(
+                    modifier = Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = dragOffsetWithTouchSlop, y = 0f))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun row_nestedScrollingBackwardOnceWeScrolledForwardPreviously() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Horizontal,
+                    state = scrollable
+                )
+            ) {
+                TvLazyRow(
+                    modifier = Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        // scroll forward
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 2)
+
+        // scroll back so we again on 0 position
+        // we scroll one extra dp to prevent rounding issues
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT, 2)
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                draggedOffset = 0f
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = dragOffsetWithTouchSlop, y = 0f))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun row_nestedScrollingForwardWhenTheFullContentIsInitiallyVisible() = runBlocking {
+        val items = (1..2).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Horizontal,
+                    state = scrollable
+                )
+            ) {
+                TvLazyRow(
+                    modifier = Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(40.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = -dragOffsetWithTouchSlop, y = 0f))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+        }
+    }
+
+    @Test
+    fun row_nestedScrollingForwardWhenScrolledToTheEnd() = runBlocking {
+        val items = (1..3).toList()
+        var draggedOffset = 0f
+        val scrollable = ScrollableState {
+            draggedOffset += it
+            it
+        }
+        rule.setContentWithTestViewConfiguration {
+            Box(
+                Modifier.scrollable(
+                    orientation = Orientation.Horizontal,
+                    state = scrollable
+                )
+            ) {
+                TvLazyRow(
+                    modifier = Modifier.requiredSize(100.dp).testTag(LazyTag),
+                    pivotOffsets = PivotOffsets(parentFraction = 0f)
+                ) {
+                    items(items) {
+                        Spacer(Modifier.requiredSize(50.dp).testTag("$it").focusable())
+                    }
+                }
+            }
+        }
+
+        // scroll till the end
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_RIGHT, 3)
+
+        rule.onNodeWithTag(LazyTag)
+            .performTouchInput {
+                draggedOffset = 0f
+                down(Offset(x = 10f, y = 10f))
+                moveBy(Offset(x = -dragOffsetWithTouchSlop, y = 0f))
+                up()
+            }
+
+        rule.runOnIdle {
+            Truth.assertThat(draggedOffset).isEqualTo(-expectedDragOffset)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyRowTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyRowTest.kt
new file mode 100644
index 0000000..13bfd51
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyRowTest.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.NativeKeyEvent
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.grid.keyPress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazyRowTest {
+    private val LazyListTag = "LazyListTag"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val firstItemTag = "firstItemTag"
+    private val secondItemTag = "secondItemTag"
+
+    private fun prepareLazyRowForAlignment(verticalGravity: Alignment.Vertical) {
+        rule.setContentWithTestViewConfiguration {
+            TvLazyRow(
+                Modifier.testTag(LazyListTag).requiredHeight(100.dp),
+                verticalAlignment = verticalGravity,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(listOf(1, 2)) {
+                    if (it == 1) {
+                        Box(Modifier.size(50.dp).testTag(firstItemTag).focusable())
+                    } else {
+                        Box(Modifier.size(70.dp).testTag(secondItemTag).focusable())
+                    }
+                }
+            }
+        }
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertIsDisplayed()
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertIsDisplayed()
+
+        val lazyRowBounds = rule.onNodeWithTag(LazyListTag)
+            .getUnclippedBoundsInRoot()
+
+        with(rule.density) {
+            // Verify the height of the row
+            assertThat(lazyRowBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+            assertThat(lazyRowBounds.bottom.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
+        }
+    }
+
+    @Test
+    fun lazyRowAlignmentCenterVertically() {
+        prepareLazyRowForAlignment(Alignment.CenterVertically)
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertPositionInRootIsEqualTo(0.dp, 25.dp)
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertPositionInRootIsEqualTo(50.dp, 15.dp)
+    }
+
+    @Test
+    fun lazyRowAlignmentTop() {
+        prepareLazyRowForAlignment(Alignment.Top)
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertPositionInRootIsEqualTo(50.dp, 0.dp)
+    }
+
+    @Test
+    fun lazyRowAlignmentBottom() {
+        prepareLazyRowForAlignment(Alignment.Bottom)
+
+        rule.onNodeWithTag(firstItemTag)
+            .assertPositionInRootIsEqualTo(0.dp, 50.dp)
+
+        rule.onNodeWithTag(secondItemTag)
+            .assertPositionInRootIsEqualTo(50.dp, 30.dp)
+    }
+
+    @Test
+    fun scrollsLeftInRtl() {
+        lateinit var state: TvLazyListState
+        rule.setContentWithTestViewConfiguration {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                Box(Modifier.width(100.dp)) {
+                    state = rememberLazyListState()
+                    TvLazyRow(
+                        Modifier.testTag(LazyListTag),
+                        state,
+                        pivotOffsets =
+                        PivotOffsets(parentFraction = 0f)
+                    ) {
+                        items(4) {
+                            Box(
+                                Modifier.width(101.dp).fillParentMaxHeight().testTag("$it")
+                                    .focusable()
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        rule.keyPress(NativeKeyEvent.KEYCODE_DPAD_LEFT, 3)
+
+        rule.runOnIdle {
+            assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+            assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyScrollAccessibilityTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyScrollAccessibilityTest.kt
new file mode 100644
index 0000000..53c9775
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyScrollAccessibilityTest.kt
@@ -0,0 +1,364 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.list
+
+import android.R.id.accessibilityActionScrollDown
+import android.R.id.accessibilityActionScrollLeft
+import android.R.id.accessibilityActionScrollRight
+import android.R.id.accessibilityActionScrollUp
+import android.view.View
+import android.view.accessibility.AccessibilityNodeProvider
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.core.view.ViewCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.IterableSubject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+class LazyScrollAccessibilityTest(private val config: TestConfig) {
+    data class TestConfig(
+        val horizontal: Boolean,
+        val rtl: Boolean,
+        val reversed: Boolean
+    ) {
+        val vertical = !horizontal
+
+        override fun toString(): String {
+            return (if (horizontal) "horizontal" else "vertical") +
+                (if (rtl) ",rtl" else ",ltr") +
+                (if (reversed) ",reversed" else "")
+        }
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() =
+            listOf(true, false).flatMap { horizontal ->
+                listOf(false, true).flatMap { rtl ->
+                    listOf(false, true).map { reversed ->
+                        TestConfig(horizontal, rtl, reversed)
+                    }
+                }
+            }
+    }
+
+    @get:Rule
+    val rule = createAndroidComposeRule<ComponentActivity>()
+
+    private val scrollerTag = "ScrollerTest"
+    private var composeView: View? = null
+    private val accessibilityNodeProvider: AccessibilityNodeProvider
+        get() = checkNotNull(composeView) {
+            "composeView not initialized. Did `composeView = LocalView.current` not work?"
+        }.let { composeView ->
+            ViewCompat
+                .getAccessibilityDelegate(composeView)!!
+                .getAccessibilityNodeProvider(composeView)!!
+                .provider as AccessibilityNodeProvider
+        }
+
+    @Test
+    fun scrollForward() {
+        testRelativeDirection(58, ACTION_SCROLL_FORWARD)
+    }
+
+    @Test
+    fun scrollBackward() {
+        testRelativeDirection(41, ACTION_SCROLL_BACKWARD)
+    }
+
+    @Test
+    fun scrollRight() {
+        testAbsoluteDirection(58, accessibilityActionScrollRight, config.horizontal)
+    }
+
+    @Test
+    fun scrollLeft() {
+        testAbsoluteDirection(41, accessibilityActionScrollLeft, config.horizontal)
+    }
+
+    @Test
+    fun scrollDown() {
+        testAbsoluteDirection(58, accessibilityActionScrollDown, config.vertical)
+    }
+
+    @Test
+    fun scrollUp() {
+        testAbsoluteDirection(41, accessibilityActionScrollUp, config.vertical)
+    }
+
+    @Test
+    fun verifyScrollActionsAtStart() {
+        createScrollableContent_StartAtStart()
+        verifyNodeInfoScrollActions(
+            expectForward = !config.reversed,
+            expectBackward = config.reversed
+        )
+    }
+
+    @Test
+    fun verifyScrollActionsInMiddle() {
+        createScrollableContent_StartInMiddle()
+        verifyNodeInfoScrollActions(
+            expectForward = true,
+            expectBackward = true
+        )
+    }
+
+    @Test
+    fun verifyScrollActionsAtEnd() {
+        createScrollableContent_StartAtEnd()
+        verifyNodeInfoScrollActions(
+            expectForward = config.reversed,
+            expectBackward = !config.reversed
+        )
+    }
+
+    /**
+     * Setup the test, run the given [accessibilityAction], and check if the [canonicalTarget]
+     * has been reached. The canonical target is the item that we expect to see when moving
+     * forward in a non-reversed scrollable (e.g. down in LazyColumn or right in LazyRow in LTR).
+     * The actual target is either the canonical target or the target that is as far from the
+     * middle of the lazy list as the canonical target, but on the other side of the middle,
+     * depending on the [configuration][config].
+     */
+    private fun testRelativeDirection(canonicalTarget: Int, accessibilityAction: Int) {
+        val target = if (!config.reversed) canonicalTarget else 100 - canonicalTarget - 1
+        testScrollAction(target, accessibilityAction)
+    }
+
+    /**
+     * Setup the test, run the given [accessibilityAction], and check if the [canonicalTarget]
+     * has been reached (but only if we [expect][expectActionSuccess] the action to succeed).
+     * The canonical target is the item that we expect to see when moving forward in a
+     * non-reversed scrollable (e.g. down in LazyColumn or right in LazyRow in LTR). The actual
+     * target is either the canonical target or the target that is as far from the middle of the
+     * scrollable as the canonical target, but on the other side of the middle, depending on the
+     * [configuration][config].
+     */
+    private fun testAbsoluteDirection(
+        canonicalTarget: Int,
+        accessibilityAction: Int,
+        expectActionSuccess: Boolean
+    ) {
+        var target = canonicalTarget
+        if (config.horizontal && config.rtl) {
+            target = 100 - target - 1
+        }
+        if (config.reversed) {
+            target = 100 - target - 1
+        }
+        testScrollAction(target, accessibilityAction, expectActionSuccess)
+    }
+
+    /**
+     * Setup the test, run the given [accessibilityAction], and check if the [target] has been
+     * reached (but only if we [expect][expectActionSuccess] the action to succeed).
+     */
+    private fun testScrollAction(
+        target: Int,
+        accessibilityAction: Int,
+        expectActionSuccess: Boolean = true
+    ) {
+        createScrollableContent_StartInMiddle()
+        rule.onNodeWithText("$target").assertDoesNotExist()
+
+        val returnValue = rule.onNodeWithTag(scrollerTag).withSemanticsNode {
+            accessibilityNodeProvider.performAction(id, accessibilityAction, null)
+        }
+
+        assertThat(returnValue).isEqualTo(expectActionSuccess)
+        if (expectActionSuccess) {
+            rule.onNodeWithText("$target").assertIsDisplayed()
+        } else {
+            rule.onNodeWithText("$target").assertDoesNotExist()
+        }
+    }
+
+    /**
+     * Checks if all of the scroll actions are present or not according to what we expect based on
+     * [expectForward] and [expectBackward]. The scroll actions that are checked are forward,
+     * backward, left, right, up and down. The expectation parameters must already account for
+     * [reversing][TestConfig.reversed].
+     */
+    private fun verifyNodeInfoScrollActions(expectForward: Boolean, expectBackward: Boolean) {
+        val nodeInfo = rule.onNodeWithTag(scrollerTag).withSemanticsNode {
+            rule.runOnUiThread {
+                accessibilityNodeProvider.createAccessibilityNodeInfo(id)
+            }
+        }
+
+        val actions = nodeInfo.actionList.map { it.id }
+
+        assertThat(actions).contains(expectForward, ACTION_SCROLL_FORWARD)
+        assertThat(actions).contains(expectBackward, ACTION_SCROLL_BACKWARD)
+
+        if (config.horizontal) {
+            val expectLeft = if (config.rtl) expectForward else expectBackward
+            val expectRight = if (config.rtl) expectBackward else expectForward
+            assertThat(actions).contains(expectLeft, accessibilityActionScrollLeft)
+            assertThat(actions).contains(expectRight, accessibilityActionScrollRight)
+            assertThat(actions).contains(false, accessibilityActionScrollDown)
+            assertThat(actions).contains(false, accessibilityActionScrollUp)
+        } else {
+            assertThat(actions).contains(false, accessibilityActionScrollLeft)
+            assertThat(actions).contains(false, accessibilityActionScrollRight)
+            assertThat(actions).contains(expectForward, accessibilityActionScrollDown)
+            assertThat(actions).contains(expectBackward, accessibilityActionScrollUp)
+        }
+    }
+
+    private fun IterableSubject.contains(expectPresent: Boolean, element: Any) {
+        if (expectPresent) {
+            contains(element)
+        } else {
+            doesNotContain(element)
+        }
+    }
+
+    /**
+     * Creates a Row/Column that starts at the first item, according to [createScrollableContent]
+     */
+    private fun createScrollableContent_StartAtStart() {
+        createScrollableContent {
+            // Start at the start:
+            // -> pretty basic
+            rememberLazyListState(0, 0)
+        }
+    }
+
+    /**
+     * Creates a Row/Column that starts in the middle, according to [createScrollableContent]
+     */
+    private fun createScrollableContent_StartInMiddle() {
+        createScrollableContent {
+            // Start at the middle:
+            // Content size: 100 items * 21dp per item = 2100dp
+            // Viewport size: 200dp rect - 50dp padding on both sides = 100dp
+            // Content outside viewport: 2100dp - 100dp = 2000dp
+            // -> centered when 1000dp on either side, which is 47 items + 13dp
+            rememberLazyListState(
+                47,
+                with(LocalDensity.current) { 13.dp.roundToPx() }
+            )
+        }
+    }
+
+    /**
+     * Creates a Row/Column that starts at the last item, according to [createScrollableContent]
+     */
+    private fun createScrollableContent_StartAtEnd() {
+        createScrollableContent {
+            // Start at the end:
+            // Content size: 100 items * 21dp per item = 2100dp
+            // Viewport size: 200dp rect - 50dp padding on both sides = 100dp
+            // Content outside viewport: 2100dp - 100dp = 2000dp
+            // -> at the end when offset at 2000dp, which is 95 items + 5dp
+            rememberLazyListState(
+                95,
+                with(LocalDensity.current) { 5.dp.roundToPx() }
+            )
+        }
+    }
+
+    /**
+     * Creates a Row/Column with a viewport of 100.dp, containing 100 items each 17.dp in size.
+     * The items have a text with their index (ASC), and where the viewport starts is determined
+     * by the given [lambda][rememberLazyListState]. All properties from [config] are applied.
+     * The viewport has padding around it to make sure scroll distance doesn't include padding.
+     */
+    private fun createScrollableContent(rememberLazyListState: @Composable () -> TvLazyListState) {
+        rule.setContent {
+            composeView = LocalView.current
+            val lazyContent: TvLazyListScope.() -> Unit = {
+                items(100) {
+                    Box(Modifier.requiredSize(21.dp).background(Color.Yellow)) {
+                        BasicText("$it", Modifier.align(Alignment.Center))
+                    }
+                }
+            }
+
+            val state = rememberLazyListState()
+
+            Box(Modifier.requiredSize(200.dp).background(Color.White)) {
+                val direction = if (config.rtl) LayoutDirection.Rtl else LayoutDirection.Ltr
+                CompositionLocalProvider(LocalLayoutDirection provides direction) {
+                    if (config.horizontal) {
+                        TvLazyRow(
+                            Modifier.testTag(scrollerTag).matchParentSize(),
+                            state = state,
+                            contentPadding = PaddingValues(50.dp),
+                            reverseLayout = config.reversed,
+                            verticalAlignment = Alignment.CenterVertically,
+                            pivotOffsets =
+                            PivotOffsets(parentFraction = 0f)
+                        ) {
+                            lazyContent()
+                        }
+                    } else {
+                        TvLazyColumn(
+                            Modifier.testTag(scrollerTag).matchParentSize(),
+                            state = state,
+                            contentPadding = PaddingValues(50.dp),
+                            reverseLayout = config.reversed,
+                            horizontalAlignment = Alignment.CenterHorizontally,
+                            pivotOffsets =
+                            PivotOffsets(parentFraction = 0f)
+                        ) {
+                            lazyContent()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun <T> SemanticsNodeInteraction.withSemanticsNode(block: SemanticsNode.() -> T): T {
+        return block.invoke(fetchSemanticsNode())
+    }
+}
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyScrollTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyScrollTest.kt
new file mode 100644
index 0000000..e8416f6
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazyScrollTest.kt
@@ -0,0 +1,340 @@
+/*
+ * Copyright 2020 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.animation.core.FloatSpringSpec
+import androidx.tv.foundation.lazy.AutoTestFrameClock
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Dp
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.PivotOffsets
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import java.util.concurrent.TimeUnit
+import kotlin.math.roundToInt
+
+@MediumTest
+@RunWith(Parameterized::class)
+class LazyScrollTest(private val orientation: Orientation) {
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val vertical: Boolean
+        get() = orientation == Orientation.Vertical
+
+    private val itemsCount = 20
+    private lateinit var state: TvLazyListState
+
+    private val itemSizePx = 100
+    private var itemSizeDp = Dp.Unspecified
+    private var containerSizeDp = Dp.Unspecified
+
+    lateinit var scope: CoroutineScope
+
+    @Before
+    fun setup() {
+        with(rule.density) {
+            itemSizeDp = itemSizePx.toDp()
+            containerSizeDp = itemSizeDp * 3
+        }
+        rule.setContent {
+            state = rememberLazyListState()
+            scope = rememberCoroutineScope()
+            TestContent()
+        }
+    }
+
+    @Test
+    fun setupWorks() {
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+    }
+
+    @Test
+    fun scrollToItem() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(3)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+    }
+
+    @Test
+    fun scrollToItemWithOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(3, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+    }
+
+    @Test
+    fun scrollToItemWithNegativeOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(3, -10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+        val item3Offset = state.layoutInfo.visibleItemsInfo.first { it.index == 3 }.offset
+        assertThat(item3Offset).isEqualTo(10)
+    }
+
+    @Test
+    fun scrollToItemWithPositiveOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(itemsCount - 3, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 3)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not 10
+    }
+
+    @Test
+    fun scrollToItemWithNegativeOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(1, -(itemSizePx + 10))
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not -10
+    }
+
+    @Test
+    fun scrollToItemWithIndexLargerThanItemsCount() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.scrollToItem(itemsCount + 2)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 3)
+    }
+
+    @Test
+    fun animateScrollBy() = runBlocking {
+        val scrollDistance = 320
+
+        val expectedIndex = scrollDistance / itemSizePx // resolves to 3
+        val expectedOffset = scrollDistance % itemSizePx // resolves to 20px
+
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollBy(scrollDistance.toFloat())
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(expectedIndex)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
+    }
+
+    @Test
+    fun animateScrollToItem() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(5, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(5)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+    }
+
+    @Test
+    fun animateScrollToItemWithOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(3, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+    }
+
+    @Test
+    fun animateScrollToItemWithNegativeOffset() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(3, -10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+        val item3Offset = state.layoutInfo.visibleItemsInfo.first { it.index == 3 }.offset
+        assertThat(item3Offset).isEqualTo(10)
+    }
+
+    @Test
+    fun animateScrollToItemWithPositiveOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(itemsCount - 3, 10)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 3)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not 10
+    }
+
+    @Test
+    fun animateScrollToItemWithNegativeOffsetLargerThanAvailableSize() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(1, -(itemSizePx + 10))
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0) // not -10
+    }
+
+    @Test
+    fun animateScrollToItemWithIndexLargerThanItemsCount() = runBlocking {
+        withContext(Dispatchers.Main + AutoTestFrameClock()) {
+            state.animateScrollToItem(itemsCount + 2)
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(itemsCount - 3)
+    }
+
+    @Test
+    fun animatePerFrameForwardToVisibleItem() {
+        assertSpringAnimation(toIndex = 2)
+    }
+
+    @Test
+    fun animatePerFrameForwardToVisibleItemWithOffset() {
+        assertSpringAnimation(toIndex = 2, toOffset = 35)
+    }
+
+    @Test
+    fun animatePerFrameForwardToNotVisibleItem() {
+        assertSpringAnimation(toIndex = 8)
+    }
+
+    @Test
+    fun animatePerFrameForwardToNotVisibleItemWithOffset() {
+        assertSpringAnimation(toIndex = 10, toOffset = 35)
+    }
+
+    @Test
+    fun animatePerFrameBackward() {
+        assertSpringAnimation(toIndex = 1, fromIndex = 6)
+    }
+
+    @Test
+    fun animatePerFrameBackwardWithOffset() {
+        assertSpringAnimation(toIndex = 1, fromIndex = 5, fromOffset = 58)
+    }
+
+    @Test
+    fun animatePerFrameBackwardWithInitialOffset() {
+        assertSpringAnimation(toIndex = 0, toOffset = 20, fromIndex = 8)
+    }
+
+    private fun assertSpringAnimation(
+        toIndex: Int,
+        toOffset: Int = 0,
+        fromIndex: Int = 0,
+        fromOffset: Int = 0
+    ) {
+        if (fromIndex != 0 || fromOffset != 0) {
+            rule.runOnIdle {
+                runBlocking {
+                    state.scrollToItem(fromIndex, fromOffset)
+                }
+            }
+        }
+        rule.waitForIdle()
+
+        assertThat(state.firstVisibleItemIndex).isEqualTo(fromIndex)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(fromOffset)
+
+        rule.mainClock.autoAdvance = false
+
+        scope.launch {
+            state.animateScrollToItem(toIndex, toOffset)
+        }
+
+        while (!state.isScrollInProgress) {
+            Thread.sleep(5)
+        }
+
+        val startOffset = (fromIndex * itemSizePx + fromOffset).toFloat()
+        val endOffset = (toIndex * itemSizePx + toOffset).toFloat()
+        val spec = FloatSpringSpec()
+
+        val duration =
+            TimeUnit.NANOSECONDS.toMillis(spec.getDurationNanos(startOffset, endOffset, 0f))
+        rule.mainClock.advanceTimeByFrame()
+        var expectedTime = rule.mainClock.currentTime
+        for (i in 0..duration step FrameDuration) {
+            val nanosTime = TimeUnit.MILLISECONDS.toNanos(i)
+            val expectedValue =
+                spec.getValueFromNanos(nanosTime, startOffset, endOffset, 0f)
+            val actualValue =
+                (state.firstVisibleItemIndex * itemSizePx + state.firstVisibleItemScrollOffset)
+            assertWithMessage(
+                "On animation frame at $i index=${state.firstVisibleItemIndex} " +
+                    "offset=${state.firstVisibleItemScrollOffset} expectedValue=$expectedValue"
+            ).that(actualValue).isEqualTo(expectedValue.roundToInt(), tolerance = 1)
+
+            rule.mainClock.advanceTimeBy(FrameDuration)
+            expectedTime += FrameDuration
+            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            rule.waitForIdle()
+        }
+        assertThat(state.firstVisibleItemIndex).isEqualTo(toIndex)
+        assertThat(state.firstVisibleItemScrollOffset).isEqualTo(toOffset)
+    }
+
+    @Composable
+    private fun TestContent() {
+        if (vertical) {
+            TvLazyColumn(
+                Modifier.height(containerSizeDp),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(itemsCount) {
+                    ItemContent()
+                }
+            }
+        } else {
+            TvLazyRow(
+                Modifier.width(containerSizeDp),
+                state,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(itemsCount) {
+                    ItemContent()
+                }
+            }
+        }
+    }
+
+    @Composable
+    private fun ItemContent() {
+        val modifier = if (vertical) {
+            Modifier.height(itemSizeDp)
+        } else {
+            Modifier.width(itemSizeDp)
+        }
+        Spacer(modifier)
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
+    }
+}
+
+private val FrameDuration = 16L
diff --git a/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazySemanticsTest.kt b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazySemanticsTest.kt
new file mode 100644
index 0000000..2ac1492
--- /dev/null
+++ b/tv/tv-foundation/src/androidAndroidTest/kotlin/androidx/tv/compose/foundation/lazy/list/LazySemanticsTest.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2021 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 androidx.tv.compose.foundation.lazy.list
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions.ScrollToIndex
+import androidx.compose.ui.semantics.SemanticsProperties.IndexForKey
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.tv.foundation.PivotOffsets
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests the semantics properties defined on a LazyList:
+ * - GetIndexForKey
+ * - ScrollToIndex
+ *
+ * GetIndexForKey:
+ * Create a lazy list, iterate over all indices, verify key of each of them
+ *
+ * ScrollToIndex:
+ * Create a lazy list, scroll to an item off screen, verify shown items
+ *
+ * All tests performed in [runTest], scenarios set up in the test methods.
+ */
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LazySemanticsTest {
+    private val N = 20
+    private val LazyListTag = "lazy_list"
+    private val LazyListModifier = Modifier.testTag(LazyListTag).requiredSize(100.dp)
+
+    private fun tag(index: Int): String = "tag_$index"
+    private fun key(index: Int): String = "key_$index"
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun itemSemantics_column() {
+        rule.setContent {
+            TvLazyColumn(
+                LazyListModifier,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                repeat(N) {
+                    item(key = key(it)) {
+                        SpacerInColumn(it)
+                    }
+                }
+            }
+        }
+        runTest()
+    }
+
+    @Test
+    fun itemsSemantics_column() {
+        rule.setContent {
+            TvLazyColumn(
+                LazyListModifier,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(items = List(N) { it }, key = { key(it) }) {
+                    SpacerInColumn(it)
+                }
+            }
+        }
+        runTest()
+    }
+
+    @Test
+    fun itemSemantics_row() {
+        rule.setContent {
+            TvLazyRow(
+                LazyListModifier,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                repeat(N) {
+                    item(key = key(it)) {
+                        SpacerInRow(it)
+                    }
+                }
+            }
+        }
+        runTest()
+    }
+
+    @Test
+    fun itemsSemantics_row() {
+        rule.setContent {
+            TvLazyRow(
+                LazyListModifier,
+                pivotOffsets = PivotOffsets(parentFraction = 0f)
+            ) {
+                items(items = List(N) { it }, key = { key(it) }) {
+                    SpacerInRow(it)
+                }
+            }
+        }
+        runTest()
+    }
+
+    private fun runTest() {
+        checkViewport(firstExpectedItem = 0, lastExpectedItem = 3)
+
+        // Verify IndexForKey
+        rule.onNodeWithTag(LazyListTag).assert(
+            SemanticsMatcher.keyIsDefined(IndexForKey).and(
+                SemanticsMatcher("keys match") { node ->
+                    val actualIndex = node.config.getOrNull(IndexForKey)!!
+                    (0 until N).all { expectedIndex ->
+                        expectedIndex == actualIndex.invoke(key(expectedIndex))
+                    }
+                }
+            )
+        )
+
+        // Verify ScrollToIndex
+        rule.onNodeWithTag(LazyListTag).assert(SemanticsMatcher.keyIsDefined(ScrollToIndex))
+
+        invokeScrollToIndex(targetIndex = 10)
+        checkViewport(firstExpectedItem = 10, lastExpectedItem = 13)
+
+        invokeScrollToIndex(targetIndex = N - 1)
+        checkViewport(firstExpectedItem = N - 4, lastExpectedItem = N - 1)
+    }
+
+    private fun invokeScrollToIndex(targetIndex: Int) {
+        val node = rule.onNodeWithTag(LazyListTag)
+            .fetchSemanticsNode("Failed: invoke ScrollToIndex")
+        rule.runOnUiThread {
+            node.config[ScrollToIndex].action!!.invoke(targetIndex)
+        }
+    }
+
+    private fun checkViewport(firstExpectedItem: Int, lastExpectedItem: Int) {
+        if (firstExpectedItem > 0) {
+            rule.onNodeWithTag(tag(firstExpectedItem - 1)).assertDoesNotExist()
+        }
+        (firstExpectedItem..lastExpectedItem).forEach {
+            rule.onNodeWithTag(tag(it)).assertExists()
+        }
+        if (firstExpectedItem < N - 1) {
+            rule.onNodeWithTag(tag(lastExpectedItem + 1)).assertDoesNotExist()
+        }
+    }
+
+    @Composable
+    private fun SpacerInColumn(index: Int) {
+        Spacer(Modifier.testTag(tag(index)).requiredHeight(30.dp).fillMaxWidth())
+    }
+
+    @Composable
+    private fun SpacerInRow(index: Int) {
+        Spacer(Modifier.testTag(tag(index)).requiredWidth(30.dp).fillMaxHeight())
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/MarioScrollable.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/MarioScrollable.kt
new file mode 100644
index 0000000..00904a5
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/MarioScrollable.kt
@@ -0,0 +1,323 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.Orientation.Horizontal
+import androidx.compose.foundation.gestures.Orientation.Vertical
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.onFocusedBoundsChanged
+import androidx.compose.foundation.relocation.BringIntoViewResponder
+import androidx.compose.foundation.relocation.bringIntoViewResponder
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.OnPlacedModifier
+import androidx.compose.ui.layout.OnRemeasuredModifier
+import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.modifier.modifierLocalOf
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.toSize
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/* Copied from
+ compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/
+ Scrollable.kt and modified */
+
+/**
+ * Configure touch scrolling and flinging for the UI element in a single [Orientation].
+ *
+ * Users should update their state themselves using default [ScrollableState] and its
+ * `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
+ * their own state in UI when using this component.
+ *
+ * @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
+ * interpreted by the user land logic and contains useful information about on-going events.
+ * @param orientation orientation of the scrolling
+ * @param pivotOffsets offsets of child element within the parent and starting edge of the child
+ * from the pivot defined by the parentOffset.
+ * @param enabled whether or not scrolling in enabled
+ * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will
+ * behave like bottom to top and left to right will behave like right to left.
+ * drag events when this scrollable is being dragged.
+ */
+
+@OptIn(ExperimentalFoundationApi::class)
+fun Modifier.marioScrollable(
+    state: ScrollableState,
+    orientation: Orientation,
+    pivotOffsets: PivotOffsets,
+    enabled: Boolean = true,
+    reverseDirection: Boolean = false
+): Modifier = composed(
+    inspectorInfo = debugInspectorInfo {
+        name = "marioScrollable"
+        properties["orientation"] = orientation
+        properties["state"] = state
+        properties["enabled"] = enabled
+        properties["reverseDirection"] = reverseDirection
+        properties["pivotOffsets"] = pivotOffsets
+    },
+    factory = {
+        val coroutineScope = rememberCoroutineScope()
+        val keepFocusedChildInViewModifier =
+            remember(coroutineScope, orientation, state, reverseDirection) {
+                ContentInViewModifier(
+                    coroutineScope, orientation, state, reverseDirection, pivotOffsets)
+            }
+
+        Modifier
+            .focusGroup()
+            .then(keepFocusedChildInViewModifier.modifier)
+            .pointerScrollable(
+                orientation,
+                reverseDirection,
+                state,
+                enabled
+            )
+            .then(if (enabled) ModifierLocalScrollableContainerProvider else Modifier)
+    }
+)
+
+@Suppress("ComposableModifierFactory")
+@Composable
+private fun Modifier.pointerScrollable(
+    orientation: Orientation,
+    reverseDirection: Boolean,
+    controller: ScrollableState,
+    enabled: Boolean
+): Modifier {
+    val nestedScrollDispatcher = remember { mutableStateOf(NestedScrollDispatcher()) }
+    val scrollLogic = rememberUpdatedState(
+        ScrollingLogic(
+            orientation,
+            reverseDirection,
+            controller
+        )
+    )
+    val nestedScrollConnection = remember(enabled) {
+        scrollableNestedScrollConnection(scrollLogic, enabled)
+    }
+
+    return this.nestedScroll(nestedScrollConnection, nestedScrollDispatcher.value)
+}
+
+private class ScrollingLogic(
+    val orientation: Orientation,
+    val reverseDirection: Boolean,
+    val scrollableState: ScrollableState,
+) {
+    private fun Float.toOffset(): Offset = when {
+        this == 0f -> Offset.Zero
+        orientation == Horizontal -> Offset(this, 0f)
+        else -> Offset(0f, this)
+    }
+
+    private fun Offset.toFloat(): Float =
+        if (orientation == Horizontal) this.x else this.y
+    private fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this
+
+    fun performRawScroll(scroll: Offset): Offset {
+        return if (scrollableState.isScrollInProgress) {
+            Offset.Zero
+        } else {
+            scrollableState.dispatchRawDelta(scroll.toFloat().reverseIfNeeded())
+                .reverseIfNeeded().toOffset()
+        }
+    }
+}
+
+private fun scrollableNestedScrollConnection(
+    scrollLogic: State<ScrollingLogic>,
+    enabled: Boolean
+): NestedScrollConnection = object : NestedScrollConnection {
+    override fun onPostScroll(
+        consumed: Offset,
+        available: Offset,
+        source: NestedScrollSource
+    ): Offset = if (enabled) {
+        scrollLogic.value.performRawScroll(available)
+    } else {
+        Offset.Zero
+    }
+}
+
+/**
+ * Handles any logic related to bringing or keeping content in view, including
+ * [BringIntoViewResponder] and ensuring the focused child stays in view when the scrollable area
+ * is shrunk.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+private class ContentInViewModifier(
+    private val scope: CoroutineScope,
+    private val orientation: Orientation,
+    private val scrollableState: ScrollableState,
+    private val reverseDirection: Boolean,
+    private val pivotOffsets: PivotOffsets
+) : BringIntoViewResponder, OnRemeasuredModifier, OnPlacedModifier {
+    private var focusedChild: LayoutCoordinates? = null
+    private var coordinates: LayoutCoordinates? = null
+    private var oldSize: IntSize? = null
+
+    val modifier: Modifier = this
+        .onFocusedBoundsChanged { focusedChild = it }
+        .bringIntoViewResponder(this)
+
+    override fun onRemeasured(size: IntSize) {
+        val coordinates = coordinates
+        val oldSize = oldSize
+        // We only care when this node becomes smaller than it previously was, so don't care about
+        // the initial measurement.
+        if (oldSize != null && oldSize != size && coordinates?.isAttached == true) {
+            onSizeChanged(coordinates, oldSize)
+        }
+        this.oldSize = size
+    }
+
+    override fun onPlaced(coordinates: LayoutCoordinates) {
+        this.coordinates = coordinates
+    }
+
+    override fun calculateRectForParent(localRect: Rect): Rect {
+        val oldSize = checkNotNull(oldSize) {
+            "Expected BringIntoViewRequester to not be used before parents are placed."
+        }
+        // oldSize will only be null before the initial measurement.
+        return computeDestination(localRect, oldSize, pivotOffsets)
+    }
+
+    override suspend fun bringChildIntoView(localRect: Rect) {
+        performBringIntoView(localRect, calculateRectForParent(localRect))
+    }
+
+    private fun onSizeChanged(coordinates: LayoutCoordinates, oldSize: IntSize) {
+        val containerShrunk = if (orientation == Horizontal) {
+            coordinates.size.width < oldSize.width
+        } else {
+            coordinates.size.height < oldSize.height
+        }
+        // If the container is growing, then if the focused child is only partially visible it will
+        // soon be _more_ visible, so don't scroll.
+        if (!containerShrunk) return
+
+        val focusedBounds = focusedChild
+            ?.let { coordinates.localBoundingBoxOf(it, clipBounds = false) }
+            ?: return
+        val myOldBounds = Rect(Offset.Zero, oldSize.toSize())
+        val adjustedBounds = computeDestination(focusedBounds, coordinates.size, pivotOffsets)
+        val wasVisible = myOldBounds.overlaps(focusedBounds)
+        val isFocusedChildClipped = adjustedBounds != focusedBounds
+
+        if (wasVisible && isFocusedChildClipped) {
+            scope.launch {
+                performBringIntoView(focusedBounds, adjustedBounds)
+            }
+        }
+    }
+
+    /**
+     * Compute the destination given the source rectangle and current bounds.
+     *
+     * @param source The bounding box of the item that sent the request to be brought into view.
+     * @param pivotOffsets offsets of child element within the parent and starting edge of the child
+     * from the pivot defined by the parentOffset.
+     * @return the destination rectangle.
+     */
+    private fun computeDestination(
+        source: Rect,
+        intSize: IntSize,
+        pivotOffsets: PivotOffsets
+    ): Rect {
+        val size = intSize.toSize()
+        return when (orientation) {
+            Vertical ->
+                source.translate(
+                    0f,
+                    relocationDistance(source.top, source.bottom, size.height, pivotOffsets))
+            Horizontal ->
+                source.translate(
+                    relocationDistance(source.left, source.right, size.width, pivotOffsets),
+                    0f)
+        }
+    }
+
+    /**
+     * Using the source and destination bounds, perform an animated scroll.
+     */
+    private suspend fun performBringIntoView(source: Rect, destination: Rect) {
+        val offset = when (orientation) {
+            Vertical -> source.top - destination.top
+            Horizontal -> source.left - destination.left
+        }
+        val scrollDelta = if (reverseDirection) -offset else offset
+
+        // Note that this results in weird behavior if called before the previous
+        // performBringIntoView finishes due to b/220119990.
+        scrollableState.animateScrollBy(scrollDelta)
+    }
+
+    /**
+     * Calculate the offset needed to bring one of the edges into view. The leadingEdge is the side
+     * closest to the origin (For the x-axis this is 'left', for the y-axis this is 'top').
+     * The trailing edge is the other side (For the x-axis this is 'right', for the y-axis this is
+     * 'bottom').
+     */
+    private fun relocationDistance(
+        leadingEdgeOfItemRequestingFocus: Float,
+        trailingEdgeOfItemRequestingFocus: Float,
+        parentSize: Float,
+        pivotOffsets: PivotOffsets
+    ): Float {
+        val totalWidthOfItemRequestingFocus =
+            trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus
+        val pivotOfItemRequestingFocus =
+            pivotOffsets.childFraction * totalWidthOfItemRequestingFocus
+        val intendedLocationOfItemRequestingFocus = parentSize * pivotOffsets.parentFraction
+
+        return leadingEdgeOfItemRequestingFocus - intendedLocationOfItemRequestingFocus +
+            pivotOfItemRequestingFocus
+    }
+}
+
+// TODO: b/203141462 - make this public and move it to ui
+/**
+ * Whether this modifier is inside a scrollable container, provided by [Modifier.marioScrollable].
+ * Defaults to false.
+ */
+internal val ModifierLocalScrollableContainer = modifierLocalOf { false }
+
+private object ModifierLocalScrollableContainerProvider : ModifierLocalProvider<Boolean> {
+    override val key = ModifierLocalScrollableContainer
+    override val value = true
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/PivotOffsets.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/PivotOffsets.kt
new file mode 100644
index 0000000..2700311
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/PivotOffsets.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation
+
+/**
+ * Holds the offsets needed for mario-scrolling.
+ *
+ * {@property parentFraction} defines the offset of the starting edge of the child
+ * element from the starting edge of the parent element. This value should be between 0 and 1.
+ *
+ * {@property childFraction} defines the offset of the starting edge of the child from
+ * the pivot defined by parentFraction. This value should be between 0 and 1.
+ */
+class PivotOffsets constructor(
+    val parentFraction: Float = 0.3f,
+    val childFraction: Float = 0f
+) {
+    init {
+        validateFraction(parentFraction)
+        validateFraction(childFraction)
+    }
+
+    /* Verify that the fraction passed in lies between 0 and 1 */
+    private fun validateFraction(fraction: Float): Float {
+        if (fraction in 0.0..1.0)
+            return fraction
+        else
+            throw IllegalArgumentException(
+                "OffsetFractions should be between 0 and 1. $fraction is not between 0 and 1.")
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is PivotOffsets) return false
+
+        if (parentFraction != other.parentFraction) return false
+        if (childFraction != other.childFraction) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = parentFraction.hashCode()
+        result = 31 * result + childFraction.hashCode()
+        return result
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyBeyondBoundsModifier.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyBeyondBoundsModifier.kt
new file mode 100644
index 0000000..d0611207
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyBeyondBoundsModifier.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy
+
+import androidx.tv.foundation.lazy.LazyListBeyondBoundsInfo.Interval
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.BeyondBoundsLayout
+import androidx.compose.ui.layout.BeyondBoundsLayout.BeyondBoundsScope
+import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Above
+import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.After
+import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Before
+import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Below
+import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Left
+import androidx.compose.ui.layout.BeyondBoundsLayout.LayoutDirection.Companion.Right
+import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
+import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.modifier.ProvidableModifierLocal
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.LayoutDirection.Ltr
+import androidx.compose.ui.unit.LayoutDirection.Rtl
+import androidx.tv.foundation.lazy.list.TvLazyListState
+
+/**
+ * This modifier is used to measure and place additional items when the lazyList receives a
+ * request to layout items beyond the visible bounds.
+ */
+@Suppress("ComposableModifierFactory")
+@Composable
+internal fun Modifier.lazyListBeyondBoundsModifier(
+    state: TvLazyListState,
+    beyondBoundsInfo: LazyListBeyondBoundsInfo,
+    reverseLayout: Boolean,
+): Modifier {
+    val layoutDirection = LocalLayoutDirection.current
+    return this then remember(state, beyondBoundsInfo, reverseLayout, layoutDirection) {
+        LazyListBeyondBoundsModifierLocal(state, beyondBoundsInfo, reverseLayout, layoutDirection)
+    }
+}
+
+private class LazyListBeyondBoundsModifierLocal(
+    private val state: TvLazyListState,
+    private val beyondBoundsInfo: LazyListBeyondBoundsInfo,
+    private val reverseLayout: Boolean,
+    private val layoutDirection: LayoutDirection
+) : ModifierLocalProvider<BeyondBoundsLayout?>, BeyondBoundsLayout {
+    override val key: ProvidableModifierLocal<BeyondBoundsLayout?>
+        get() = ModifierLocalBeyondBoundsLayout
+    override val value: BeyondBoundsLayout
+        get() = this
+
+    override fun <T> layout(
+        direction: BeyondBoundsLayout.LayoutDirection,
+        block: BeyondBoundsScope.() -> T?
+    ): T? {
+        // We use a new interval each time because this function is re-entrant.
+        var interval = beyondBoundsInfo.addInterval(
+            state.firstVisibleItemIndex,
+            state.layoutInfo.visibleItemsInfo.last().index
+        )
+
+        var found: T? = null
+        while (found == null && interval.hasMoreContent(direction)) {
+
+            // Add one extra beyond bounds item.
+            interval = addNextInterval(interval, direction).also {
+                beyondBoundsInfo.removeInterval(interval)
+            }
+            state.remeasurement?.forceRemeasure()
+
+            // When we invoke this block, the beyond bounds items are present.
+            found = block.invoke(
+                object : BeyondBoundsScope {
+                    override val hasMoreContent: Boolean
+                        get() = interval.hasMoreContent(direction)
+                }
+            )
+        }
+
+        // Dispose the items that are beyond the visible bounds.
+        beyondBoundsInfo.removeInterval(interval)
+        state.remeasurement?.forceRemeasure()
+        return found
+    }
+
+    private fun addNextInterval(
+        currentInterval: Interval,
+        direction: BeyondBoundsLayout.LayoutDirection
+    ): Interval {
+        var start = currentInterval.start
+        var end = currentInterval.end
+        when (direction) {
+            Before -> start--
+            After -> end++
+            Above -> if (reverseLayout) end++ else start--
+            Below -> if (reverseLayout) start-- else end++
+            Left -> when (layoutDirection) {
+                Ltr -> if (reverseLayout) end++ else start--
+                Rtl -> if (reverseLayout) start-- else end++
+            }
+            Right -> when (layoutDirection) {
+                Ltr -> if (reverseLayout) start-- else end++
+                Rtl -> if (reverseLayout) end++ else start--
+            }
+            else -> unsupportedDirection()
+        }
+        return beyondBoundsInfo.addInterval(start, end)
+    }
+
+    private fun Interval.hasMoreContent(direction: BeyondBoundsLayout.LayoutDirection): Boolean {
+        fun hasMoreItemsBefore() = start > 0
+        fun hasMoreItemsAfter() = end < state.layoutInfo.totalItemsCount - 1
+        return when (direction) {
+            Before -> hasMoreItemsBefore()
+            After -> hasMoreItemsAfter()
+            Above -> if (reverseLayout) hasMoreItemsAfter() else hasMoreItemsBefore()
+            Below -> if (reverseLayout) hasMoreItemsBefore() else hasMoreItemsAfter()
+            Left -> when (layoutDirection) {
+                Ltr -> if (reverseLayout) hasMoreItemsAfter() else hasMoreItemsBefore()
+                Rtl -> if (reverseLayout) hasMoreItemsBefore() else hasMoreItemsAfter()
+            }
+            Right -> when (layoutDirection) {
+                Ltr -> if (reverseLayout) hasMoreItemsBefore() else hasMoreItemsAfter()
+                Rtl -> if (reverseLayout) hasMoreItemsAfter() else hasMoreItemsBefore()
+            }
+            else -> unsupportedDirection()
+        }
+    }
+}
+
+private fun unsupportedDirection(): Nothing = error(
+    "Lazy list does not support beyond bounds layout for the specified direction"
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyListBeyondBoundsInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyListBeyondBoundsInfo.kt
new file mode 100644
index 0000000..05005c8
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyListBeyondBoundsInfo.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy
+
+import androidx.compose.runtime.collection.mutableVectorOf
+
+/**
+ * This data structure is used to save information about the number of "beyond bounds items"
+ * that we want to compose. These items are not within the visible bounds of the lazylist,
+ * but we compose them because they are explicitly requested through the
+ * [beyond bounds layout API][androidx.compose.ui.layout.BeyondBoundsLayout].
+ *
+ * When the LazyList receives a
+ * [searchBeyondBounds][androidx.compose.ui.layout.BeyondBoundsLayout.searchBeyondBounds] request to
+ * layout items beyond visible bounds, it creates an instance of [LazyListBeyondBoundsInfo] by using
+ * the [addInterval] function. This returns the interval of items that are currently composed,
+ * and we can edit this interval to control the number of beyond bounds items.
+ *
+ * There can be multiple intervals created at the same time, and LazyList merges all the
+ * intervals to calculate the effective beyond bounds items.
+ *
+ * The [beyond bounds layout API][androidx.compose.ui.layout.BeyondBoundsLayout] is designed to be
+ * synchronous, so once you are done using the items, call [removeInterval] to remove
+ * the extra items you had requested.
+ *
+ * Note that when you clear an interval, the items in that interval might not be cleared right
+ * away if another interval was created that has the same items. This is done to support two use
+ * cases:
+ *
+ * 1. To allow items to be pinned while they are being scrolled into view.
+ *
+ * 2. To allow users to call
+ * [searchBeyondBounds][androidx.compose.ui.layout.BeyondBoundsLayout.searchBeyondBounds]
+ * from within the completion block of another searchBeyondBounds call.
+ */
+internal class LazyListBeyondBoundsInfo {
+    private val beyondBoundsItems = mutableVectorOf<Interval>()
+
+    /**
+     * Create a beyond bounds interval. This can be used to specify which composed items we want to
+     * retain. For instance, it can be used to force the measuring of items that are beyond the
+     * visible bounds of a lazy list.
+     *
+     * @param start The starting index (inclusive) for this interval.
+     * @param end The ending index (inclusive) for this interval.
+     *
+     * @return An interval that specifies which items we want to retain.
+     */
+    fun addInterval(start: Int, end: Int): Interval {
+        return Interval(start, end).apply {
+            beyondBoundsItems.add(this)
+        }
+    }
+
+    /**
+     * Clears the specified interval. Use this to remove the interval created by [addInterval].
+     */
+    fun removeInterval(interval: Interval) {
+        beyondBoundsItems.remove(interval)
+    }
+
+    /**
+     * Returns true if there are beyond bounds intervals.
+     */
+    fun hasIntervals(): Boolean = beyondBoundsItems.isNotEmpty()
+
+    /**
+     *  The effective start index after merging all the current intervals.
+     */
+    val start: Int
+        get() {
+            var minIndex = beyondBoundsItems.first().start
+            beyondBoundsItems.forEach {
+                if (it.start < minIndex) {
+                    minIndex = it.start
+                }
+            }
+            require(minIndex >= 0)
+            return minIndex
+        }
+
+    /**
+     *  The effective end index after merging all the current intervals.
+     */
+    val end: Int
+        get() {
+            var maxIndex = beyondBoundsItems.first().end
+            beyondBoundsItems.forEach {
+                if (it.end > maxIndex) {
+                    maxIndex = it.end
+                }
+            }
+            return maxIndex
+        }
+
+    /**
+     * The Interval used to implement [LazyListBeyondBoundsInfo].
+     */
+    internal data class Interval(
+        /** The start index for the interval. */
+        val start: Int,
+
+        /** The end index for the interval. */
+        val end: Int
+    ) {
+        init {
+            require(start >= 0)
+            require(end >= start)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyListPinningModifier.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyListPinningModifier.kt
new file mode 100644
index 0000000..a726ffb
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/LazyListPinningModifier.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.ModifierLocalPinnableParent
+import androidx.compose.foundation.lazy.layout.PinnableParent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.modifier.ModifierLocalConsumer
+import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.modifier.ModifierLocalReadScope
+import androidx.compose.ui.modifier.ProvidableModifierLocal
+import androidx.tv.foundation.lazy.list.TvLazyListState
+
+/**
+ * This is a temporary placeholder implementation of pinning until we implement b/195049010.
+ */
+@Suppress("ComposableModifierFactory")
+@Composable
+internal fun Modifier.lazyListPinningModifier(
+    state: TvLazyListState,
+    beyondBoundsInfo: LazyListBeyondBoundsInfo
+): Modifier {
+    return this then remember(state, beyondBoundsInfo) {
+        LazyListPinningModifier(state, beyondBoundsInfo)
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+private class LazyListPinningModifier(
+    private val state: TvLazyListState,
+    private val beyondBoundsInfo: LazyListBeyondBoundsInfo,
+) : ModifierLocalProvider<PinnableParent?>, ModifierLocalConsumer, PinnableParent {
+    var pinnableGrandParent: PinnableParent? = null
+
+    override val key: ProvidableModifierLocal<PinnableParent?>
+        get() = ModifierLocalPinnableParent
+
+    override val value: PinnableParent
+        get() = this
+
+    override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) {
+        pinnableGrandParent = with(scope) { ModifierLocalPinnableParent.current }
+    }
+
+    override fun pinItems(): PinnableParent.PinnedItemsHandle = with(beyondBoundsInfo) {
+        if (hasIntervals()) {
+            object : PinnableParent.PinnedItemsHandle {
+                val parentPinnedItemsHandle = pinnableGrandParent?.pinItems()
+                val interval = addInterval(start, end)
+                override fun unpin() {
+                    removeInterval(interval)
+                    parentPinnedItemsHandle?.unpin()
+                    state.remeasurement?.forceRemeasure()
+                }
+            }
+        } else {
+            pinnableGrandParent?.pinItems() ?: EmptyPinnedItemsHandle
+        }
+    }
+
+    companion object {
+        private val EmptyPinnedItemsHandle = object : PinnableParent.PinnedItemsHandle {
+            override fun unpin() {}
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt
new file mode 100644
index 0000000..8ae2a25
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+/**
+ * Represents a line index in the lazy grid.
+ */
+@Suppress("NOTHING_TO_INLINE")
+@kotlin.jvm.JvmInline
+internal value class LineIndex(val value: Int) {
+    inline operator fun inc(): LineIndex = LineIndex(value + 1)
+    inline operator fun dec(): LineIndex = LineIndex(value - 1)
+    inline operator fun plus(i: Int): LineIndex = LineIndex(value + i)
+    inline operator fun minus(i: Int): LineIndex = LineIndex(value - i)
+    inline operator fun minus(i: LineIndex): LineIndex = LineIndex(value - i.value)
+    inline operator fun compareTo(other: LineIndex): Int = value - other.value
+}
+
+/**
+ * Represents an item index in the lazy grid.
+ */
+@Suppress("NOTHING_TO_INLINE")
+@kotlin.jvm.JvmInline
+internal value class ItemIndex(val value: Int) {
+    inline operator fun inc(): ItemIndex = ItemIndex(value + 1)
+    inline operator fun dec(): ItemIndex = ItemIndex(value - 1)
+    inline operator fun plus(i: Int): ItemIndex = ItemIndex(value + i)
+    inline operator fun minus(i: Int): ItemIndex = ItemIndex(value - i)
+    inline operator fun minus(i: ItemIndex): ItemIndex = ItemIndex(value - i.value)
+    inline operator fun compareTo(other: ItemIndex): Int = value - other.value
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
new file mode 100644
index 0000000..e84fadf
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
@@ -0,0 +1,357 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.checkScrollableContainerConstraints
+import androidx.compose.foundation.clipScrollableContainer
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.lazy.layout.LazyLayout
+import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.offset
+import androidx.compose.ui.util.fastForEach
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.marioScrollable
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+internal fun LazyGrid(
+    /** Modifier to be applied for the inner layout */
+    modifier: Modifier = Modifier,
+    /** State controlling the scroll position */
+    state: TvLazyGridState,
+    /** Prefix sums of cross axis sizes of slots per line, e.g. the columns for vertical grid. */
+    slotSizesSums: Density.(Constraints) -> List<Int>,
+    /** The inner padding to be added for the whole content (not for each individual item) */
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    /** reverse the direction of scrolling and layout */
+    reverseLayout: Boolean = false,
+    /** The layout orientation of the grid */
+    isVertical: Boolean,
+    /** Whether scrolling via the user gestures is allowed. */
+    userScrollEnabled: Boolean,
+    /** The vertical arrangement for items/lines. */
+    verticalArrangement: Arrangement.Vertical,
+    /** The horizontal arrangement for items/lines. */
+    horizontalArrangement: Arrangement.Horizontal,
+    /** offsets of child element within the parent and starting edge of the child from the pivot
+     * defined by the parentOffset */
+    pivotOffsets: PivotOffsets,
+    /** The content of the grid */
+    content: TvLazyGridScope.() -> Unit
+) {
+    val itemProvider = rememberItemProvider(state, content)
+
+    val scope = rememberCoroutineScope()
+    val placementAnimator = remember(state, isVertical) {
+        LazyGridItemPlacementAnimator(scope, isVertical)
+    }
+    state.placementAnimator = placementAnimator
+
+    val measurePolicy = rememberLazyGridMeasurePolicy(
+        itemProvider,
+        state,
+        slotSizesSums,
+        contentPadding,
+        reverseLayout,
+        isVertical,
+        horizontalArrangement,
+        verticalArrangement,
+        placementAnimator
+    )
+
+    state.isVertical = isVertical
+
+    ScrollPositionUpdater(itemProvider, state)
+
+    val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
+    LazyLayout(
+        modifier = modifier
+            .then(state.remeasurementModifier)
+            .then(state.awaitLayoutModifier)
+            .lazyGridSemantics(
+                itemProvider = itemProvider,
+                state = state,
+                coroutineScope = scope,
+                isVertical = isVertical,
+                reverseScrolling = reverseLayout,
+                userScrollEnabled = userScrollEnabled
+            )
+            .clipScrollableContainer(orientation)
+            .marioScrollable(
+                orientation = orientation,
+                reverseDirection = run {
+                    // A finger moves with the content, not with the viewport. Therefore,
+                    // always reverse once to have "natural" gesture that goes reversed to layout
+                    var reverseDirection = !reverseLayout
+                    // But if rtl and horizontal, things move the other way around
+                    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+                    if (isRtl && !isVertical) {
+                        reverseDirection = !reverseDirection
+                    }
+                    reverseDirection
+                },
+                state = state,
+                enabled = userScrollEnabled,
+                pivotOffsets = pivotOffsets
+            ),
+        prefetchState = state.prefetchState,
+        measurePolicy = measurePolicy,
+        itemProvider = itemProvider
+    )
+}
+
+/** Extracted to minimize the recomposition scope */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ScrollPositionUpdater(
+    itemProvider: LazyGridItemProvider,
+    state: TvLazyGridState
+) {
+    if (itemProvider.itemCount > 0) {
+        state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun rememberLazyGridMeasurePolicy(
+    /** Items provider of the list. */
+    itemProvider: LazyGridItemProvider,
+    /** The state of the list. */
+    state: TvLazyGridState,
+    /** Prefix sums of cross axis sizes of slots of the grid. */
+    slotSizesSums: Density.(Constraints) -> List<Int>,
+    /** The inner padding to be added for the whole content(nor for each individual item) */
+    contentPadding: PaddingValues,
+    /** reverse the direction of scrolling and layout */
+    reverseLayout: Boolean,
+    /** The layout orientation of the list */
+    isVertical: Boolean,
+    /** The horizontal arrangement for items. Required when isVertical is false */
+    horizontalArrangement: Arrangement.Horizontal? = null,
+    /** The vertical arrangement for items. Required when isVertical is true */
+    verticalArrangement: Arrangement.Vertical? = null,
+    /** Item placement animator. Should be notified with the measuring result */
+    placementAnimator: LazyGridItemPlacementAnimator
+) = remember<LazyLayoutMeasureScope.(Constraints) -> MeasureResult>(
+    state,
+    slotSizesSums,
+    contentPadding,
+    reverseLayout,
+    isVertical,
+    horizontalArrangement,
+    verticalArrangement,
+    placementAnimator
+) {
+    { containerConstraints ->
+        checkScrollableContainerConstraints(
+            containerConstraints,
+            if (isVertical) Orientation.Vertical else Orientation.Horizontal
+        )
+
+        // resolve content paddings
+        val startPadding =
+            if (isVertical) {
+                contentPadding.calculateLeftPadding(layoutDirection).roundToPx()
+            } else {
+                // in horizontal configuration, padding is reversed by placeRelative
+                contentPadding.calculateStartPadding(layoutDirection).roundToPx()
+            }
+
+        val endPadding =
+            if (isVertical) {
+                contentPadding.calculateRightPadding(layoutDirection).roundToPx()
+            } else {
+                // in horizontal configuration, padding is reversed by placeRelative
+                contentPadding.calculateEndPadding(layoutDirection).roundToPx()
+            }
+        val topPadding = contentPadding.calculateTopPadding().roundToPx()
+        val bottomPadding = contentPadding.calculateBottomPadding().roundToPx()
+        val totalVerticalPadding = topPadding + bottomPadding
+        val totalHorizontalPadding = startPadding + endPadding
+        val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding
+        val beforeContentPadding = when {
+            isVertical && !reverseLayout -> topPadding
+            isVertical && reverseLayout -> bottomPadding
+            !isVertical && !reverseLayout -> startPadding
+            else -> endPadding // !isVertical && reverseLayout
+        }
+        val afterContentPadding = totalMainAxisPadding - beforeContentPadding
+        val contentConstraints =
+            containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding)
+
+        state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+
+        val spanLayoutProvider = itemProvider.spanLayoutProvider
+        val resolvedSlotSizesSums = slotSizesSums(containerConstraints)
+        spanLayoutProvider.slotsPerLine = resolvedSlotSizesSums.size
+
+        // Update the state's cached Density and slotsPerLine
+        state.density = this
+        state.slotsPerLine = resolvedSlotSizesSums.size
+
+        val spaceBetweenLinesDp = if (isVertical) {
+            requireNotNull(verticalArrangement).spacing
+        } else {
+            requireNotNull(horizontalArrangement).spacing
+        }
+        val spaceBetweenLines = spaceBetweenLinesDp.roundToPx()
+        val spaceBetweenSlotsDp = if (isVertical) {
+            horizontalArrangement?.spacing ?: 0.dp
+        } else {
+            verticalArrangement?.spacing ?: 0.dp
+        }
+        val spaceBetweenSlots = spaceBetweenSlotsDp.roundToPx()
+
+        val itemsCount = itemProvider.itemCount
+
+        // can be negative if the content padding is larger than the max size from constraints
+        val mainAxisAvailableSize = if (isVertical) {
+            containerConstraints.maxHeight - totalVerticalPadding
+        } else {
+            containerConstraints.maxWidth - totalHorizontalPadding
+        }
+        val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) {
+            IntOffset(startPadding, topPadding)
+        } else {
+            // When layout is reversed and paddings together take >100% of the available space,
+            // layout size is coerced to 0 when positioning. To take that space into account,
+            // we offset start padding by negative space between paddings.
+            IntOffset(
+                if (isVertical) startPadding else startPadding + mainAxisAvailableSize,
+                if (isVertical) topPadding + mainAxisAvailableSize else topPadding
+            )
+        }
+
+        val measuredItemProvider = LazyMeasuredItemProvider(
+            itemProvider,
+            this,
+            spaceBetweenLines
+        ) { index, key, crossAxisSize, mainAxisSpacing, placeables ->
+            LazyMeasuredItem(
+                index = index,
+                key = key,
+                isVertical = isVertical,
+                crossAxisSize = crossAxisSize,
+                mainAxisSpacing = mainAxisSpacing,
+                reverseLayout = reverseLayout,
+                layoutDirection = layoutDirection,
+                beforeContentPadding = beforeContentPadding,
+                afterContentPadding = afterContentPadding,
+                visualOffset = visualItemOffset,
+                placeables = placeables,
+                placementAnimator = placementAnimator
+            )
+        }
+        val measuredLineProvider = LazyMeasuredLineProvider(
+            isVertical,
+            resolvedSlotSizesSums,
+            spaceBetweenSlots,
+            itemsCount,
+            spaceBetweenLines,
+            measuredItemProvider,
+            spanLayoutProvider
+        ) { index, items, spans, mainAxisSpacing ->
+            LazyMeasuredLine(
+                index = index,
+                items = items,
+                spans = spans,
+                isVertical = isVertical,
+                slotsPerLine = resolvedSlotSizesSums.size,
+                layoutDirection = layoutDirection,
+                mainAxisSpacing = mainAxisSpacing,
+                crossAxisSpacing = spaceBetweenSlots
+            )
+        }
+        state.prefetchInfoRetriever = { line ->
+            val lineConfiguration = spanLayoutProvider.getLineConfiguration(line.value)
+            var index = ItemIndex(lineConfiguration.firstItemIndex)
+            var slot = 0
+            val result = ArrayList<Pair<Int, Constraints>>(lineConfiguration.spans.size)
+            lineConfiguration.spans.fastForEach {
+                val span = it.currentLineSpan
+                result.add(index.value to measuredLineProvider.childConstraints(slot, span))
+                ++index
+                slot += span
+            }
+            result
+        }
+
+        val firstVisibleLineIndex: LineIndex
+        val firstVisibleLineScrollOffset: Int
+        Snapshot.withoutReadObservation {
+            if (state.firstVisibleItemIndex < itemsCount || itemsCount <= 0) {
+                firstVisibleLineIndex = spanLayoutProvider.getLineIndexOfItem(
+                    state.firstVisibleItemIndex
+                )
+                firstVisibleLineScrollOffset = state.firstVisibleItemScrollOffset
+            } else {
+                // the data set has been updated and now we have less items that we were
+                // scrolled to before
+                firstVisibleLineIndex = spanLayoutProvider.getLineIndexOfItem(itemsCount - 1)
+                firstVisibleLineScrollOffset = 0
+            }
+        }
+        measureLazyGrid(
+            itemsCount = itemsCount,
+            measuredLineProvider = measuredLineProvider,
+            measuredItemProvider = measuredItemProvider,
+            mainAxisAvailableSize = mainAxisAvailableSize,
+            slotsPerLine = resolvedSlotSizesSums.size,
+            beforeContentPadding = beforeContentPadding,
+            afterContentPadding = afterContentPadding,
+            firstVisibleLineIndex = firstVisibleLineIndex,
+            firstVisibleLineScrollOffset = firstVisibleLineScrollOffset,
+            scrollToBeConsumed = state.scrollToBeConsumed,
+            constraints = contentConstraints,
+            isVertical = isVertical,
+            verticalArrangement = verticalArrangement,
+            horizontalArrangement = horizontalArrangement,
+            reverseLayout = reverseLayout,
+            density = this,
+            placementAnimator = placementAnimator,
+            layout = { width, height, placement ->
+                layout(
+                    containerConstraints.constrainWidth(width + totalHorizontalPadding),
+                    containerConstraints.constrainHeight(height + totalVerticalPadding),
+                    emptyMap(),
+                    placement
+                )
+            }
+        ).also { state.applyMeasureResult(it) }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt
new file mode 100644
index 0000000..9321d6d
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt
@@ -0,0 +1,481 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+
+/**
+ * A lazy vertical grid layout. It composes only visible rows of the grid.
+ *
+ * @param columns describes the count and the size of the grid's columns,
+ * see [TvGridCells] doc for more information
+ * @param modifier the modifier to apply to this layout
+ * @param state the state object to be used to control or observe the list's state
+ * @param contentPadding specify a padding around the whole content
+ * @param reverseLayout reverse the direction of scrolling and layout. When `true`, items will be
+ * laid out in the reverse order  and [TvLazyGridState.firstVisibleItemIndex] == 0 means
+ * that grid is scrolled to the bottom. Note that [reverseLayout] does not change the behavior of
+ * [verticalArrangement],
+ * e.g. with [Arrangement.Top] (top) 123### (bottom) becomes (top) 321### (bottom).
+ * @param verticalArrangement The vertical arrangement of the layout's children
+ * @param horizontalArrangement The horizontal arrangement of the layout's children
+ * @param pivotOffsets offsets that are used when implementing Mario Scrolling
+ * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions
+ * is allowed. You can still scroll programmatically using the state even when it is disabled.
+ * @param pivotOffsets offsets of child element within the parent and starting edge of the child
+ * from the pivot defined by the parentOffset.
+ * @param content the [TvLazyGridScope] which describes the content
+ */
+@Composable
+fun TvLazyVerticalGrid(
+    columns: TvGridCells,
+    modifier: Modifier = Modifier,
+    state: TvLazyGridState = rememberTvLazyGridState(),
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    reverseLayout: Boolean = false,
+    verticalArrangement: Arrangement.Vertical =
+        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
+    userScrollEnabled: Boolean = true,
+    pivotOffsets: PivotOffsets = PivotOffsets(),
+    content: TvLazyGridScope.() -> Unit
+) {
+    val slotSizesSums = rememberColumnWidthSums(columns, horizontalArrangement, contentPadding)
+    LazyGrid(
+        slotSizesSums = slotSizesSums,
+        modifier = modifier,
+        state = state,
+        contentPadding = contentPadding,
+        reverseLayout = reverseLayout,
+        isVertical = true,
+        horizontalArrangement = horizontalArrangement,
+        verticalArrangement = verticalArrangement,
+        userScrollEnabled = userScrollEnabled,
+        content = content,
+        pivotOffsets = pivotOffsets
+    )
+}
+
+/**
+ * A lazy horizontal grid layout. It composes only visible columns of the grid.
+ *
+ * @param rows a class describing how cells form rows, see [TvGridCells] doc for more information
+ * @param modifier the modifier to apply to this layout
+ * @param state the state object to be used to control or observe the list's state
+ * @param contentPadding specify a padding around the whole content
+ * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
+ * composed from the end to the start and [TvLazyGridState.firstVisibleItemIndex] == 0 will mean
+ * the first item is located at the end.
+ * @param verticalArrangement The vertical arrangement of the layout's children
+ * @param horizontalArrangement The horizontal arrangement of the layout's children
+ * @param pivotOffsets offsets that are used when implementing Mario Scrolling
+ * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions
+ * is allowed. You can still scroll programmatically using the state even when it is disabled.
+ * @param pivotOffsets offsets of child element within the parent and starting edge of the child
+ * from the pivot defined by the parentOffset.
+ * @param content the [TvLazyGridScope] which describes the content
+ */
+@Composable
+fun TvLazyHorizontalGrid(
+    rows: TvGridCells,
+    modifier: Modifier = Modifier,
+    state: TvLazyGridState = rememberTvLazyGridState(),
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    reverseLayout: Boolean = false,
+    horizontalArrangement: Arrangement.Horizontal =
+        if (!reverseLayout) Arrangement.Start else Arrangement.End,
+    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
+    userScrollEnabled: Boolean = true,
+    pivotOffsets: PivotOffsets = PivotOffsets(),
+    content: TvLazyGridScope.() -> Unit
+) {
+    val slotSizesSums = rememberRowHeightSums(rows, verticalArrangement, contentPadding)
+    LazyGrid(
+        slotSizesSums = slotSizesSums,
+        modifier = modifier,
+        state = state,
+        contentPadding = contentPadding,
+        reverseLayout = reverseLayout,
+        isVertical = false,
+        horizontalArrangement = horizontalArrangement,
+        verticalArrangement = verticalArrangement,
+        userScrollEnabled = userScrollEnabled,
+        pivotOffsets = pivotOffsets,
+        content = content
+    )
+}
+
+/** Returns prefix sums of column widths. */
+@Composable
+private fun rememberColumnWidthSums(
+    columns: TvGridCells,
+    horizontalArrangement: Arrangement.Horizontal,
+    contentPadding: PaddingValues
+) = remember<Density.(Constraints) -> List<Int>>(
+    columns,
+    horizontalArrangement,
+    contentPadding,
+) {
+    { constraints ->
+        require(constraints.maxWidth != Constraints.Infinity) {
+            "LazyVerticalGrid's width should be bound by parent."
+        }
+        val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) +
+            contentPadding.calculateEndPadding(LayoutDirection.Ltr)
+        val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx()
+        with(columns) {
+            calculateCrossAxisCellSizes(
+                gridWidth,
+                horizontalArrangement.spacing.roundToPx()
+            ).toMutableList().apply {
+                for (i in 1 until size) {
+                    this[i] += this[i - 1]
+                }
+            }
+        }
+    }
+}
+
+/** Returns prefix sums of row heights. */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun rememberRowHeightSums(
+    rows: TvGridCells,
+    verticalArrangement: Arrangement.Vertical,
+    contentPadding: PaddingValues
+) = remember<Density.(Constraints) -> List<Int>>(
+    rows,
+    verticalArrangement,
+    contentPadding,
+) {
+    { constraints ->
+        require(constraints.maxHeight != Constraints.Infinity) {
+            "LazyHorizontalGrid's height should be bound by parent."
+        }
+        val verticalPadding = contentPadding.calculateTopPadding() +
+            contentPadding.calculateBottomPadding()
+        val gridHeight = constraints.maxHeight - verticalPadding.roundToPx()
+        with(rows) {
+            calculateCrossAxisCellSizes(
+                gridHeight,
+                verticalArrangement.spacing.roundToPx()
+            ).toMutableList().apply {
+                for (i in 1 until size) {
+                    this[i] += this[i - 1]
+                }
+            }
+        }
+    }
+}
+
+/**
+ * This class describes the count and the sizes of columns in vertical grids,
+ * or rows in horizontal grids.
+ */
+@Stable
+interface TvGridCells {
+    /**
+     * Calculates the number of cells and their cross axis size based on
+     * [availableSize] and [spacing].
+     *
+     * For example, in vertical grids, [spacing] is passed from the grid's [Arrangement.Horizontal].
+     * The [Arrangement.Horizontal] will also be used to arrange items in a row if the grid is wider
+     * than the calculated sum of columns.
+     *
+     * Note that the calculated cross axis sizes will be considered in an RTL-aware manner --
+     * if the grid is vertical and the layout direction is RTL, the first width in the returned
+     * list will correspond to the rightmost column.
+     *
+     * @param availableSize available size on cross axis, e.g. width of [TvLazyVerticalGrid].
+     * @param spacing cross axis spacing, e.g. horizontal spacing for [TvLazyVerticalGrid].
+     * The spacing is passed from the corresponding [Arrangement] param of the lazy grid.
+     */
+    fun Density.calculateCrossAxisCellSizes(availableSize: Int, spacing: Int): List<Int>
+
+    /**
+     * Defines a grid with fixed number of rows or columns.
+     *
+     * For example, for the vertical [TvLazyVerticalGrid] Fixed(3) would mean that
+     * there are 3 columns 1/3 of the parent width.
+     */
+    class Fixed(private val count: Int) : TvGridCells {
+        init {
+            require(count > 0)
+        }
+
+        override fun Density.calculateCrossAxisCellSizes(
+            availableSize: Int,
+            spacing: Int
+        ): List<Int> {
+            return calculateCellsCrossAxisSizeImpl(availableSize, count, spacing)
+        }
+
+        override fun hashCode(): Int {
+            return -count // Different sign from Adaptive.
+        }
+
+        override fun equals(other: Any?): Boolean {
+            return other is Fixed && count == other.count
+        }
+    }
+
+    /**
+     * Defines a grid with as many rows or columns as possible on the condition that
+     * every cell has at least [minSize] space and all extra space distributed evenly.
+     *
+     * For example, for the vertical [TvLazyVerticalGrid] Adaptive(20.dp) would mean that
+     * there will be as many columns as possible and every column will be at least 20.dp
+     * and all the columns will have equal width. If the screen is 88.dp wide then
+     * there will be 4 columns 22.dp each.
+     */
+    class Adaptive(private val minSize: Dp) : TvGridCells {
+        init {
+            require(minSize > 0.dp)
+        }
+
+        override fun Density.calculateCrossAxisCellSizes(
+            availableSize: Int,
+            spacing: Int
+        ): List<Int> {
+            val count = maxOf((availableSize + spacing) / (minSize.roundToPx() + spacing), 1)
+            return calculateCellsCrossAxisSizeImpl(availableSize, count, spacing)
+        }
+
+        override fun hashCode(): Int {
+            return minSize.hashCode()
+        }
+
+        override fun equals(other: Any?): Boolean {
+            return other is Adaptive && minSize == other.minSize
+        }
+    }
+}
+
+private fun calculateCellsCrossAxisSizeImpl(
+    gridSize: Int,
+    slotCount: Int,
+    spacing: Int
+): List<Int> {
+    val gridSizeWithoutSpacing = gridSize - spacing * (slotCount - 1)
+    val slotSize = gridSizeWithoutSpacing / slotCount
+    val remainingPixels = gridSizeWithoutSpacing % slotCount
+    return List(slotCount) {
+        slotSize + if (it < remainingPixels) 1 else 0
+    }
+}
+
+/**
+ * Receiver scope which is used by [TvLazyVerticalGrid].
+ */
+@TvLazyGridScopeMarker
+sealed interface TvLazyGridScope {
+    /**
+     * Adds a single item to the scope.
+     *
+     * @param key a stable and unique key representing the item. Using the same key
+     * for multiple items in the grid is not allowed. Type of the key should be saveable
+     * via Bundle on Android. If null is passed the position in the grid will represent the key.
+     * When you specify the key the scroll position will be maintained based on the key, which
+     * means if you add/remove items before the current visible item the item with the given key
+     * will be kept as the first visible one.
+     * @param span the span of the item. Default is 1x1. It is good practice to leave it `null`
+     * when this matches the intended behavior, as providing a custom implementation impacts
+     * performance
+     * @param contentType the type of the content of this item. The item compositions of the same
+     * type could be reused more efficiently. Note that null is a valid type and items of such
+     * type will be considered compatible.
+     * @param content the content of the item
+     */
+    fun item(
+        key: Any? = null,
+        span: (TvLazyGridItemSpanScope.() -> TvGridItemSpan)? = null,
+        contentType: Any? = null,
+        content: @Composable TvLazyGridItemScope.() -> Unit
+    )
+
+    /**
+     * Adds a [count] of items.
+     *
+     * @param count the items count
+     * @param key a factory of stable and unique keys representing the item. Using the same key
+     * for multiple items in the grid is not allowed. Type of the key should be saveable
+     * via Bundle on Android. If null is passed the position in the grid will represent the key.
+     * When you specify the key the scroll position will be maintained based on the key, which
+     * means if you add/remove items before the current visible item the item with the given key
+     * will be kept as the first visible one.
+     * @param span define custom spans for the items. Default is 1x1. It is good practice to
+     * leave it `null` when this matches the intended behavior, as providing a custom
+     * implementation impacts performance
+     * @param contentType a factory of the content types for the item. The item compositions of
+     * the same type could be reused more efficiently. Note that null is a valid type and items
+     * of such type will be considered compatible.
+     * @param itemContent the content displayed by a single item
+     */
+    fun items(
+        count: Int,
+        key: ((index: Int) -> Any)? = null,
+        span: (TvLazyGridItemSpanScope.(index: Int) -> TvGridItemSpan)? = null,
+        contentType: (index: Int) -> Any? = { null },
+        itemContent: @Composable TvLazyGridItemScope.(index: Int) -> Unit
+    )
+}
+
+/**
+ * Adds a list of items.
+ *
+ * @param items the data list
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the grid is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the grid will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param span define custom spans for the items. Default is 1x1. It is good practice to
+ * leave it `null` when this matches the intended behavior, as providing a custom implementation
+ * impacts performance
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyGridScope.items(
+    items: List<T>,
+    noinline key: ((item: T) -> Any)? = null,
+    noinline span: (TvLazyGridItemSpanScope.(item: T) -> TvGridItemSpan)? = null,
+    noinline contentType: (item: T) -> Any? = { null },
+    crossinline itemContent: @Composable TvLazyGridItemScope.(item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(items[index]) } else null,
+    span = if (span != null) { { span(items[it]) } } else null,
+    contentType = { index: Int -> contentType(items[index]) }
+) {
+    itemContent(items[it])
+}
+
+/**
+ * Adds a list of items where the content of an item is aware of its index.
+ *
+ * @param items the data list
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the grid is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the grid will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param span define custom spans for the items. Default is 1x1. It is good practice to leave
+ * it `null` when this matches the intended behavior, as providing a custom implementation
+ * impacts performance
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyGridScope.itemsIndexed(
+    items: List<T>,
+    noinline key: ((index: Int, item: T) -> Any)? = null,
+    noinline span: (TvLazyGridItemSpanScope.(index: Int, item: T) -> TvGridItemSpan)? = null,
+    crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
+    crossinline itemContent: @Composable TvLazyGridItemScope.(index: Int, item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(index, items[index]) } else null,
+    span = if (span != null) { { span(it, items[it]) } } else null,
+    contentType = { index -> contentType(index, items[index]) }
+) {
+    itemContent(it, items[it])
+}
+
+/**
+ * Adds an array of items.
+ *
+ * @param items the data array
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the grid is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the grid will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param span define custom spans for the items. Default is 1x1. It is good practice to leave
+ * it `null` when this matches the intended behavior, as providing a custom implementation
+ * impacts performance
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyGridScope.items(
+    items: Array<T>,
+    noinline key: ((item: T) -> Any)? = null,
+    noinline span: (TvLazyGridItemSpanScope.(item: T) -> TvGridItemSpan)? = null,
+    noinline contentType: (item: T) -> Any? = { null },
+    crossinline itemContent: @Composable TvLazyGridItemScope.(item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(items[index]) } else null,
+    span = if (span != null) { { span(items[it]) } } else null,
+    contentType = { index: Int -> contentType(items[index]) }
+) {
+    itemContent(items[it])
+}
+
+/**
+ * Adds an array of items where the content of an item is aware of its index.
+ *
+ * @param items the data array
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the grid is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the grid will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param span define custom spans for the items. Default is 1x1. It is good practice to leave
+ * it `null` when this matches the intended behavior, as providing a custom implementation
+ * impacts performance
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyGridScope.itemsIndexed(
+    items: Array<T>,
+    noinline key: ((index: Int, item: T) -> Any)? = null,
+    noinline span: (TvLazyGridItemSpanScope.(index: Int, item: T) -> TvGridItemSpan)? = null,
+    crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
+    crossinline itemContent: @Composable TvLazyGridItemScope.(index: Int, item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(index, items[index]) } else null,
+    span = if (span != null) { { span(it, items[it]) } } else null,
+    contentType = { index -> contentType(index, items[index]) }
+) {
+    itemContent(it, items[it])
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
new file mode 100644
index 0000000..223640a
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
@@ -0,0 +1,463 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
+import kotlin.math.absoluteValue
+import kotlin.math.max
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Handles the item placement animations when it is set via
+ * [TvLazyGridItemScope.animateItemPlacement].
+ *
+ * This class is responsible for detecting when item position changed, figuring our start/end
+ * offsets and starting the animations.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyGridItemPlacementAnimator(
+    private val scope: CoroutineScope,
+    private val isVertical: Boolean
+) {
+    private var slotsPerLine = 0
+
+    // state containing an animation and all relevant info for each item.
+    private val keyToItemInfoMap = mutableMapOf<Any, ItemInfo>()
+
+    // snapshot of the key to index map used for the last measuring.
+    private var keyToIndexMap: Map<Any, Int> = emptyMap()
+
+    // keeps the first and the last items positioned in the viewport and their visible part sizes.
+    private var viewportStartItemIndex = -1
+    private var viewportStartItemNotVisiblePartSize = 0
+    private var viewportEndItemIndex = -1
+    private var viewportEndItemNotVisiblePartSize = 0
+
+    // stored to not allocate it every pass.
+    private val positionedKeys = mutableSetOf<Any>()
+
+    /**
+     * Should be called after the measuring so we can detect position changes and start animations.
+     *
+     * Note that this method can compose new item and add it into the [positionedItems] list.
+     */
+    fun onMeasured(
+        consumedScroll: Int,
+        layoutWidth: Int,
+        layoutHeight: Int,
+        slotsPerLine: Int,
+        reverseLayout: Boolean,
+        positionedItems: MutableList<TvLazyGridPositionedItem>,
+        measuredItemProvider: LazyMeasuredItemProvider,
+    ) {
+        if (!positionedItems.fastAny { it.hasAnimations }) {
+            // no animations specified - no work needed
+            reset()
+            return
+        }
+
+        this.slotsPerLine = slotsPerLine
+
+        val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+
+        // the consumed scroll is considered as a delta we don't need to animate
+        val notAnimatableDelta = (if (reverseLayout) -consumedScroll else consumedScroll).toOffset()
+
+        val newFirstItem = positionedItems.first()
+        val newLastItem = positionedItems.last()
+
+        positionedItems.fastForEach { item ->
+            val itemInfo = keyToItemInfoMap[item.key] ?: return@fastForEach
+            itemInfo.index = item.index
+            itemInfo.crossAxisSize = item.getCrossAxisSize()
+            itemInfo.crossAxisOffset = item.getCrossAxisOffset()
+        }
+
+        val averageLineMainAxisSize = run {
+            val lineOf: (Int) -> Int = {
+                if (isVertical) positionedItems[it].row else positionedItems[it].column
+            }
+
+            var totalLinesMainAxisSize = 0
+            var linesCount = 0
+
+            var lineStartIndex = 0
+            while (lineStartIndex < positionedItems.size) {
+                val currentLine = lineOf(lineStartIndex)
+                if (currentLine == -1) {
+                    // Filter out exiting items.
+                    ++lineStartIndex
+                    continue
+                }
+
+                var lineMainAxisSize = 0
+                var lineEndIndex = lineStartIndex
+                while (lineEndIndex < positionedItems.size && lineOf(lineEndIndex) == currentLine) {
+                    lineMainAxisSize = max(
+                        lineMainAxisSize,
+                        positionedItems[lineEndIndex].mainAxisSizeWithSpacings
+                    )
+                    ++lineEndIndex
+                }
+
+                totalLinesMainAxisSize += lineMainAxisSize
+                ++linesCount
+
+                lineStartIndex = lineEndIndex
+            }
+
+            totalLinesMainAxisSize / linesCount
+        }
+
+        positionedKeys.clear()
+        // iterate through the items which are visible (without animated offsets)
+        positionedItems.fastForEach { item ->
+            positionedKeys.add(item.key)
+            val itemInfo = keyToItemInfoMap[item.key]
+            if (itemInfo == null) {
+                // there is no state associated with this item yet
+                if (item.hasAnimations) {
+                    val newItemInfo = ItemInfo(
+                        item.index,
+                        item.getCrossAxisSize(),
+                        item.getCrossAxisOffset()
+                    )
+                    val previousIndex = keyToIndexMap[item.key]
+                    val offset = item.placeableOffset
+
+                    val targetPlaceableOffsetMainAxis = if (previousIndex == null) {
+                        // it is a completely new item. no animation is needed
+                        offset.mainAxis
+                    } else {
+                        val fallback = if (!reverseLayout) {
+                            offset.mainAxis
+                        } else {
+                            offset.mainAxis - item.mainAxisSizeWithSpacings
+                        }
+                        calculateExpectedOffset(
+                            index = previousIndex,
+                            mainAxisSizeWithSpacings = item.mainAxisSizeWithSpacings,
+                            averageLineMainAxisSize = averageLineMainAxisSize,
+                            scrolledBy = notAnimatableDelta,
+                            fallback = fallback,
+                            reverseLayout = reverseLayout,
+                            mainAxisLayoutSize = mainAxisLayoutSize
+                        )
+                    }
+                    val targetPlaceableOffset = if (isVertical) {
+                        offset.copy(y = targetPlaceableOffsetMainAxis)
+                    } else {
+                        offset.copy(x = targetPlaceableOffsetMainAxis)
+                    }
+
+                    // populate placeable info list
+                    repeat(item.placeablesCount) { placeableIndex ->
+                        newItemInfo.placeables.add(
+                            PlaceableInfo(
+                                targetPlaceableOffset,
+                                item.getMainAxisSize(placeableIndex)
+                            )
+                        )
+                    }
+                    keyToItemInfoMap[item.key] = newItemInfo
+                    startAnimationsIfNeeded(item, newItemInfo)
+                }
+            } else {
+                if (item.hasAnimations) {
+                    // apply new not animatable offset
+                    itemInfo.notAnimatableDelta += notAnimatableDelta
+                    startAnimationsIfNeeded(item, itemInfo)
+                } else {
+                    // no animation, clean up if needed
+                    keyToItemInfoMap.remove(item.key)
+                }
+            }
+        }
+
+        // previously we were animating items which are visible in the end state so we had to
+        // compare the current state with the state used for the previous measuring.
+        // now we will animate disappearing items so the current state is their starting state
+        // so we can update current viewport start/end items
+
+        if (!reverseLayout) {
+            viewportStartItemIndex = newFirstItem.index
+            viewportStartItemNotVisiblePartSize = newFirstItem.offset.mainAxis
+            viewportEndItemIndex = newLastItem.index
+            viewportEndItemNotVisiblePartSize = newLastItem.offset.mainAxis +
+                newLastItem.lineMainAxisSizeWithSpacings - mainAxisLayoutSize
+        } else {
+            viewportStartItemIndex = newLastItem.index
+            viewportStartItemNotVisiblePartSize = mainAxisLayoutSize -
+                newLastItem.offset.mainAxis - newLastItem.lineMainAxisSize
+            viewportEndItemIndex = newFirstItem.index
+            viewportEndItemNotVisiblePartSize = -newFirstItem.offset.mainAxis +
+                (newFirstItem.lineMainAxisSizeWithSpacings -
+                    if (isVertical) newFirstItem.size.height else newFirstItem.size.width)
+        }
+
+        val iterator = keyToItemInfoMap.iterator()
+        while (iterator.hasNext()) {
+            val entry = iterator.next()
+            if (!positionedKeys.contains(entry.key)) {
+                // found an item which was in our map previously but is not a part of the
+                // positionedItems now
+                val itemInfo = entry.value
+                // apply new not animatable delta for this item
+                itemInfo.notAnimatableDelta += notAnimatableDelta
+
+                val index = measuredItemProvider.keyToIndexMap[entry.key]
+
+                // whether at least one placeable is within the viewport bounds.
+                // this usually means that we will start animation for it right now
+                val withinBounds = itemInfo.placeables.fastAny {
+                    val currentTarget = it.targetOffset + itemInfo.notAnimatableDelta
+                    currentTarget.mainAxis + it.mainAxisSize > 0 &&
+                        currentTarget.mainAxis < mainAxisLayoutSize
+                }
+
+                // whether the animation associated with the item has been finished
+                val isFinished = !itemInfo.placeables.fastAny { it.inProgress }
+
+                if ((!withinBounds && isFinished) ||
+                    index == null ||
+                    itemInfo.placeables.isEmpty()
+                ) {
+                    iterator.remove()
+                } else {
+                    // not sure if this item will end up on the last line or not. assume not,
+                    // therefore leave the mainAxisSpacing to be the default one
+                    val measuredItem = measuredItemProvider.getAndMeasure(
+                        index = ItemIndex(index),
+                        constraints = if (isVertical) {
+                            Constraints.fixedWidth(itemInfo.crossAxisSize)
+                        } else {
+                            Constraints.fixedHeight(itemInfo.crossAxisSize)
+                        }
+                    )
+
+                    // calculate the target offset for the animation.
+                    val absoluteTargetOffset = calculateExpectedOffset(
+                        index = index,
+                        mainAxisSizeWithSpacings = measuredItem.mainAxisSizeWithSpacings,
+                        averageLineMainAxisSize = averageLineMainAxisSize,
+                        scrolledBy = notAnimatableDelta,
+                        fallback = mainAxisLayoutSize,
+                        reverseLayout = reverseLayout,
+                        mainAxisLayoutSize = mainAxisLayoutSize
+                    )
+                    val targetOffset = if (reverseLayout) {
+                        mainAxisLayoutSize - absoluteTargetOffset - measuredItem.mainAxisSize
+                    } else {
+                        absoluteTargetOffset
+                    }
+
+                    val item = measuredItem.position(
+                        targetOffset,
+                        itemInfo.crossAxisOffset,
+                        layoutWidth,
+                        layoutHeight,
+                        TvLazyGridItemInfo.UnknownRow,
+                        TvLazyGridItemInfo.UnknownColumn,
+                        measuredItem.mainAxisSize
+                    )
+                    positionedItems.add(item)
+                    startAnimationsIfNeeded(item, itemInfo)
+                }
+            }
+        }
+
+        keyToIndexMap = measuredItemProvider.keyToIndexMap
+    }
+
+    /**
+     * Returns the current animated item placement offset. By calling it only during the layout
+     * phase we can skip doing remeasure on every animation frame.
+     */
+    fun getAnimatedOffset(
+        key: Any,
+        placeableIndex: Int,
+        minOffset: Int,
+        maxOffset: Int,
+        rawOffset: IntOffset
+    ): IntOffset {
+        val itemInfo = keyToItemInfoMap[key] ?: return rawOffset
+        val item = itemInfo.placeables[placeableIndex]
+        val currentValue = item.animatedOffset.value + itemInfo.notAnimatableDelta
+        val currentTarget = item.targetOffset + itemInfo.notAnimatableDelta
+
+        // cancel the animation if it is fully out of the bounds.
+        if (item.inProgress &&
+            ((currentTarget.mainAxis < minOffset && currentValue.mainAxis < minOffset) ||
+            (currentTarget.mainAxis > maxOffset && currentValue.mainAxis > maxOffset))
+        ) {
+            scope.launch {
+                item.animatedOffset.snapTo(item.targetOffset)
+                item.inProgress = false
+            }
+        }
+
+        return currentValue
+    }
+
+    /**
+     * Should be called when the animations are not needed for the next positions change,
+     * for example when we snap to a new position.
+     */
+    fun reset() {
+        keyToItemInfoMap.clear()
+        keyToIndexMap = emptyMap()
+        viewportStartItemIndex = -1
+        viewportStartItemNotVisiblePartSize = 0
+        viewportEndItemIndex = -1
+        viewportEndItemNotVisiblePartSize = 0
+    }
+
+    /**
+     * Estimates the outside of the viewport offset for the item. Used to understand from
+     * where to start animation for the item which wasn't visible previously or where it should
+     * end for the item which is not going to be visible in the end.
+     */
+    private fun calculateExpectedOffset(
+        index: Int,
+        mainAxisSizeWithSpacings: Int,
+        averageLineMainAxisSize: Int,
+        scrolledBy: IntOffset,
+        reverseLayout: Boolean,
+        mainAxisLayoutSize: Int,
+        fallback: Int
+    ): Int {
+        require(slotsPerLine != 0)
+        val beforeViewportStart =
+            if (!reverseLayout) viewportEndItemIndex < index else viewportEndItemIndex > index
+        val afterViewportEnd =
+            if (!reverseLayout) viewportStartItemIndex > index else viewportStartItemIndex < index
+        return when {
+            beforeViewportStart -> {
+                val diff = ((index - viewportEndItemIndex).absoluteValue + slotsPerLine - 1) /
+                    slotsPerLine
+                mainAxisLayoutSize + viewportEndItemNotVisiblePartSize +
+                    averageLineMainAxisSize * (diff - 1) +
+                    scrolledBy.mainAxis
+            }
+            afterViewportEnd -> {
+                val diff = ((viewportStartItemIndex - index).absoluteValue + slotsPerLine - 1) /
+                    slotsPerLine
+                viewportStartItemNotVisiblePartSize - mainAxisSizeWithSpacings -
+                    averageLineMainAxisSize * (diff - 1) +
+                    scrolledBy.mainAxis
+            }
+            else -> {
+                fallback
+            }
+        }
+    }
+
+    private fun startAnimationsIfNeeded(item: TvLazyGridPositionedItem, itemInfo: ItemInfo) {
+        // first we make sure our item info is up to date (has the item placeables count)
+        while (itemInfo.placeables.size > item.placeablesCount) {
+            itemInfo.placeables.removeLast()
+        }
+        while (itemInfo.placeables.size < item.placeablesCount) {
+            val newPlaceableInfoIndex = itemInfo.placeables.size
+            val rawOffset = item.offset
+            itemInfo.placeables.add(
+                PlaceableInfo(
+                    rawOffset - itemInfo.notAnimatableDelta,
+                    item.getMainAxisSize(newPlaceableInfoIndex)
+                )
+            )
+        }
+
+        itemInfo.placeables.fastForEachIndexed { index, placeableInfo ->
+            val currentTarget = placeableInfo.targetOffset + itemInfo.notAnimatableDelta
+            val currentOffset = item.placeableOffset
+            placeableInfo.mainAxisSize = item.getMainAxisSize(index)
+            val animationSpec = item.getAnimationSpec(index)
+            if (currentTarget != currentOffset) {
+                placeableInfo.targetOffset = currentOffset - itemInfo.notAnimatableDelta
+                if (animationSpec != null) {
+                    placeableInfo.inProgress = true
+                    scope.launch {
+                        val finalSpec = if (placeableInfo.animatedOffset.isRunning) {
+                            // when interrupted, use the default spring, unless the spec is a spring.
+                            if (animationSpec is SpringSpec<IntOffset>) animationSpec else
+                                InterruptionSpec
+                        } else {
+                            animationSpec
+                        }
+
+                        try {
+                            placeableInfo.animatedOffset.animateTo(
+                                placeableInfo.targetOffset,
+                                finalSpec
+                            )
+                            placeableInfo.inProgress = false
+                        } catch (_: CancellationException) {
+                            // we don't reset inProgress in case of cancellation as it means
+                            // there is a new animation started which would reset it later
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun Int.toOffset() =
+        IntOffset(if (isVertical) 0 else this, if (!isVertical) 0 else this)
+
+    private val IntOffset.mainAxis get() = if (isVertical) y else x
+}
+
+private class ItemInfo(
+    var index: Int,
+    var crossAxisSize: Int,
+    var crossAxisOffset: Int
+) {
+    var notAnimatableDelta: IntOffset = IntOffset.Zero
+    val placeables = mutableListOf<PlaceableInfo>()
+}
+
+private class PlaceableInfo(initialOffset: IntOffset, var mainAxisSize: Int) {
+    val animatedOffset = Animatable(initialOffset, IntOffset.VectorConverter)
+    var targetOffset: IntOffset = initialOffset
+    var inProgress by mutableStateOf(false)
+}
+
+/**
+ * We switch to this spec when a duration based animation is being interrupted.
+ */
+private val InterruptionSpec = spring(
+    stiffness = Spring.StiffnessMediumLow,
+    visibilityThreshold = IntOffset.VisibilityThreshold
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
new file mode 100644
index 0000000..bfab189
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal interface LazyGridItemProvider : LazyLayoutItemProvider {
+    val spanLayoutProvider: LazyGridSpanLayoutProvider
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemsProviderImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemsProviderImpl.kt
new file mode 100644
index 0000000..9ee4e5e
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemsProviderImpl.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.IntervalList
+import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.Snapshot
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+@Composable
+internal fun rememberItemProvider(
+    state: TvLazyGridState,
+    content: TvLazyGridScope.() -> Unit,
+): LazyGridItemProvider {
+    val latestContent = rememberUpdatedState(content)
+    // mutableState + LaunchedEffect below are used instead of derivedStateOf to ensure that update
+    // of derivedState in return expr will only happen after the state value has been changed.
+    val nearestItemsRangeState = remember(state) {
+        mutableStateOf(
+            Snapshot.withoutReadObservation {
+                // State read is observed in composition, causing it to recompose 1 additional time.
+                calculateNearestItemsRange(state.firstVisibleItemIndex)
+            }
+        )
+    }
+    LaunchedEffect(nearestItemsRangeState) {
+        snapshotFlow { calculateNearestItemsRange(state.firstVisibleItemIndex) }
+            // MutableState's SnapshotMutationPolicy will make sure the provider is only
+            // recreated when the state is updated with a new range.
+            .collect { nearestItemsRangeState.value = it }
+    }
+    return remember(nearestItemsRangeState) {
+        LazyGridItemProviderImpl(
+            derivedStateOf {
+                val listScope = TvLazyGridScopeImpl().apply(latestContent.value)
+                LazyGridItemsSnapshot(
+                    listScope.intervals,
+                    listScope.hasCustomSpans,
+                    nearestItemsRangeState.value
+                )
+            }
+        )
+    }
+}
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal class LazyGridItemsSnapshot(
+    private val intervals: IntervalList<LazyGridIntervalContent>,
+    val hasCustomSpans: Boolean,
+    nearestItemsRange: IntRange
+) {
+    val itemsCount get() = intervals.size
+
+    val spanLayoutProvider = LazyGridSpanLayoutProvider(this)
+
+    fun getKey(index: Int): Any {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        val key = interval.value.key?.invoke(localIntervalIndex)
+        return key ?: getDefaultLazyLayoutKey(index)
+    }
+
+    fun TvLazyGridItemSpanScope.getSpan(index: Int): TvGridItemSpan {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        return interval.value.span.invoke(this, localIntervalIndex)
+    }
+
+    @Composable
+    fun Item(index: Int) {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        interval.value.item.invoke(TvLazyGridItemScopeImpl, localIntervalIndex)
+    }
+
+    val keyToIndexMap: Map<Any, Int> = generateKeyToIndexMap(nearestItemsRange, intervals)
+
+    fun getContentType(index: Int): Any? {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        return interval.value.type.invoke(localIntervalIndex)
+    }
+}
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal class LazyGridItemProviderImpl(
+    private val itemsSnapshot: State<LazyGridItemsSnapshot>
+) : LazyGridItemProvider {
+
+    override val itemCount get() = itemsSnapshot.value.itemsCount
+
+    override fun getKey(index: Int) = itemsSnapshot.value.getKey(index)
+
+    @Composable
+    override fun Item(index: Int) {
+        itemsSnapshot.value.Item(index)
+    }
+
+    override val keyToIndexMap: Map<Any, Int> get() = itemsSnapshot.value.keyToIndexMap
+
+    override fun getContentType(index: Int) = itemsSnapshot.value.getContentType(index)
+
+    override val spanLayoutProvider: LazyGridSpanLayoutProvider
+        get() = itemsSnapshot.value.spanLayoutProvider
+}
+
+/**
+ * Traverses the interval [list] in order to create a mapping from the key to the index for all
+ * the indexes in the passed [range].
+ * The returned map will not contain the values for intervals with no key mapping provided.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal fun generateKeyToIndexMap(
+    range: IntRange,
+    list: IntervalList<LazyGridIntervalContent>
+): Map<Any, Int> {
+    val first = range.first
+    check(first >= 0)
+    val last = minOf(range.last, list.size - 1)
+    return if (last < first) {
+        emptyMap()
+    } else {
+        hashMapOf<Any, Int>().also { map ->
+            list.forEach(
+                fromIndex = first,
+                toIndex = last,
+            ) {
+                if (it.value.key != null) {
+                    val keyFactory = requireNotNull(it.value.key)
+                    val start = maxOf(first, it.startIndex)
+                    val end = minOf(last, it.startIndex + it.size - 1)
+                    for (i in start..end) {
+                        map[keyFactory(i - it.startIndex)] = i
+                    }
+                }
+            }
+        }
+    }
+}
+
+/**
+ * Returns a range of indexes which contains at least [ExtraItemsNearTheSlidingWindow] items near
+ * the first visible item. It is optimized to return the same range for small changes in the
+ * firstVisibleItem value so we do not regenerate the map on each scroll.
+ */
+private fun calculateNearestItemsRange(firstVisibleItem: Int): IntRange {
+    val slidingWindowStart = VisibleItemsSlidingWindowSize *
+        (firstVisibleItem / VisibleItemsSlidingWindowSize)
+
+    val start = maxOf(slidingWindowStart - ExtraItemsNearTheSlidingWindow, 0)
+    val end = slidingWindowStart + VisibleItemsSlidingWindowSize + ExtraItemsNearTheSlidingWindow
+    return start until end
+}
+
+/**
+ * We use the idea of sliding window as an optimization, so user can scroll up to this number of
+ * items until we have to regenerate the key to index map.
+ */
+private val VisibleItemsSlidingWindowSize = 90
+
+/**
+ * The minimum amount of items near the current first visible item we want to have mapping for.
+ */
+private val ExtraItemsNearTheSlidingWindow = 200
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
new file mode 100644
index 0000000..13569c4
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastSumBy
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlin.math.sign
+
+/**
+ * Measures and calculates the positions for the currently visible items. The result is produced
+ * as a [TvLazyGridMeasureResult] which contains all the calculations.
+ */
+internal fun measureLazyGrid(
+    itemsCount: Int,
+    measuredLineProvider: LazyMeasuredLineProvider,
+    measuredItemProvider: LazyMeasuredItemProvider,
+    mainAxisAvailableSize: Int,
+    slotsPerLine: Int,
+    beforeContentPadding: Int,
+    afterContentPadding: Int,
+    firstVisibleLineIndex: LineIndex,
+    firstVisibleLineScrollOffset: Int,
+    scrollToBeConsumed: Float,
+    constraints: Constraints,
+    isVertical: Boolean,
+    verticalArrangement: Arrangement.Vertical?,
+    horizontalArrangement: Arrangement.Horizontal?,
+    reverseLayout: Boolean,
+    density: Density,
+    placementAnimator: LazyGridItemPlacementAnimator,
+    layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
+): TvLazyGridMeasureResult {
+    require(beforeContentPadding >= 0)
+    require(afterContentPadding >= 0)
+    if (itemsCount <= 0) {
+        // empty data set. reset the current scroll and report zero size
+        return TvLazyGridMeasureResult(
+            firstVisibleLine = null,
+            firstVisibleLineScrollOffset = 0,
+            canScrollForward = false,
+            consumedScroll = 0f,
+            measureResult = layout(constraints.minWidth, constraints.minHeight) {},
+            visibleItemsInfo = emptyList(),
+            viewportStartOffset = -beforeContentPadding,
+            viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
+            totalItemsCount = 0,
+            reverseLayout = reverseLayout,
+            orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
+            afterContentPadding = afterContentPadding
+        )
+    } else {
+        var currentFirstLineIndex = firstVisibleLineIndex
+        var currentFirstLineScrollOffset = firstVisibleLineScrollOffset
+
+        // represents the real amount of scroll we applied as a result of this measure pass.
+        var scrollDelta = scrollToBeConsumed.roundToInt()
+
+        // applying the whole requested scroll offset. we will figure out if we can't consume
+        // all of it later
+        currentFirstLineScrollOffset -= scrollDelta
+
+        // if the current scroll offset is less than minimally possible
+        if (currentFirstLineIndex == LineIndex(0) && currentFirstLineScrollOffset < 0) {
+            scrollDelta += currentFirstLineScrollOffset
+            currentFirstLineScrollOffset = 0
+        }
+
+        // this will contain all the MeasuredItems representing the visible lines
+        val visibleLines = mutableListOf<LazyMeasuredLine>()
+
+        // include the start padding so we compose items in the padding area. before starting
+        // scrolling forward we would remove it back
+        currentFirstLineScrollOffset -= beforeContentPadding
+
+        // define min and max offsets (min offset currently includes beforeContentPadding)
+        val minOffset = -beforeContentPadding
+        val maxOffset = mainAxisAvailableSize
+
+        // we had scrolled backward or we compose items in the start padding area, which means
+        // items before current firstLineScrollOffset should be visible. compose them and update
+        // firstLineScrollOffset
+        while (currentFirstLineScrollOffset < 0 && currentFirstLineIndex > LineIndex(0)) {
+            val previous = LineIndex(currentFirstLineIndex.value - 1)
+            val measuredLine = measuredLineProvider.getAndMeasure(previous)
+            visibleLines.add(0, measuredLine)
+            currentFirstLineScrollOffset += measuredLine.mainAxisSizeWithSpacings
+            currentFirstLineIndex = previous
+        }
+        // if we were scrolled backward, but there were not enough lines before. this means
+        // not the whole scroll was consumed
+        if (currentFirstLineScrollOffset < minOffset) {
+            scrollDelta += currentFirstLineScrollOffset
+            currentFirstLineScrollOffset = minOffset
+        }
+
+        // neutralize previously added start padding as we stopped filling the before content padding
+        currentFirstLineScrollOffset += beforeContentPadding
+
+        var index = currentFirstLineIndex
+        val maxMainAxis = (maxOffset + afterContentPadding).coerceAtLeast(0)
+        var currentMainAxisOffset = -currentFirstLineScrollOffset
+
+        // first we need to skip lines we already composed while composing backward
+        visibleLines.fastForEach {
+            index++
+            currentMainAxisOffset += it.mainAxisSizeWithSpacings
+        }
+
+        // then composing visible lines forward until we fill the whole viewport.
+        // we want to have at least one line in visibleItems even if in fact all the items are
+        // offscreen, this can happen if the content padding is larger than the available size.
+        while (currentMainAxisOffset <= maxMainAxis || visibleLines.isEmpty()) {
+            val measuredLine = measuredLineProvider.getAndMeasure(index)
+            if (measuredLine.isEmpty()) {
+                --index
+                break
+            }
+
+            currentMainAxisOffset += measuredLine.mainAxisSizeWithSpacings
+            if (currentMainAxisOffset <= minOffset &&
+                measuredLine.items.last().index.value != itemsCount - 1) {
+                // this line is offscreen and will not be placed. advance firstVisibleLineIndex
+                currentFirstLineIndex = index + 1
+                currentFirstLineScrollOffset -= measuredLine.mainAxisSizeWithSpacings
+            } else {
+                visibleLines.add(measuredLine)
+            }
+            index++
+        }
+
+        // we didn't fill the whole viewport with lines starting from firstVisibleLineIndex.
+        // lets try to scroll back if we have enough lines before firstVisibleLineIndex.
+        if (currentMainAxisOffset < maxOffset) {
+            val toScrollBack = maxOffset - currentMainAxisOffset
+            currentFirstLineScrollOffset -= toScrollBack
+            currentMainAxisOffset += toScrollBack
+            while (currentFirstLineScrollOffset < beforeContentPadding &&
+                currentFirstLineIndex > LineIndex(0)
+            ) {
+                val previousIndex = LineIndex(currentFirstLineIndex.value - 1)
+                val measuredLine = measuredLineProvider.getAndMeasure(previousIndex)
+                visibleLines.add(0, measuredLine)
+                currentFirstLineScrollOffset += measuredLine.mainAxisSizeWithSpacings
+                currentFirstLineIndex = previousIndex
+            }
+            scrollDelta += toScrollBack
+            if (currentFirstLineScrollOffset < 0) {
+                scrollDelta += currentFirstLineScrollOffset
+                currentMainAxisOffset += currentFirstLineScrollOffset
+                currentFirstLineScrollOffset = 0
+            }
+        }
+
+        // report the amount of pixels we consumed. scrollDelta can be smaller than
+        // scrollToBeConsumed if there were not enough lines to fill the offered space or it
+        // can be larger if lines were resized, or if, for example, we were previously
+        // displaying the line 15, but now we have only 10 lines in total in the data set.
+        val consumedScroll = if (scrollToBeConsumed.roundToInt().sign == scrollDelta.sign &&
+            abs(scrollToBeConsumed.roundToInt()) >= abs(scrollDelta)
+        ) {
+            scrollDelta.toFloat()
+        } else {
+            scrollToBeConsumed
+        }
+
+        // the initial offset for lines from visibleLines list
+        val visibleLinesScrollOffset = -currentFirstLineScrollOffset
+        var firstLine = visibleLines.first()
+
+        // even if we compose lines to fill before content padding we should ignore lines fully
+        // located there for the state's scroll position calculation (first line + first offset)
+        if (beforeContentPadding > 0) {
+            for (i in visibleLines.indices) {
+                val size = visibleLines[i].mainAxisSizeWithSpacings
+                if (currentFirstLineScrollOffset != 0 && size <= currentFirstLineScrollOffset &&
+                    i != visibleLines.lastIndex) {
+                    currentFirstLineScrollOffset -= size
+                    firstLine = visibleLines[i + 1]
+                } else {
+                    break
+                }
+            }
+        }
+
+        val layoutWidth = if (isVertical) {
+            constraints.maxWidth
+        } else {
+            constraints.constrainWidth(currentMainAxisOffset)
+        }
+        val layoutHeight = if (isVertical) {
+            constraints.constrainHeight(currentMainAxisOffset)
+        } else {
+            constraints.maxHeight
+        }
+
+        val positionedItems = calculateItemsOffsets(
+            lines = visibleLines,
+            layoutWidth = layoutWidth,
+            layoutHeight = layoutHeight,
+            finalMainAxisOffset = currentMainAxisOffset,
+            maxOffset = maxOffset,
+            firstLineScrollOffset = visibleLinesScrollOffset,
+            isVertical = isVertical,
+            verticalArrangement = verticalArrangement,
+            horizontalArrangement = horizontalArrangement,
+            reverseLayout = reverseLayout,
+            density = density
+        )
+
+        placementAnimator.onMeasured(
+            consumedScroll = consumedScroll.toInt(),
+            layoutWidth = layoutWidth,
+            layoutHeight = layoutHeight,
+            slotsPerLine = slotsPerLine,
+            reverseLayout = reverseLayout,
+            positionedItems = positionedItems,
+            measuredItemProvider = measuredItemProvider
+        )
+
+        return TvLazyGridMeasureResult(
+            firstVisibleLine = firstLine,
+            firstVisibleLineScrollOffset = currentFirstLineScrollOffset,
+            canScrollForward = currentMainAxisOffset > maxOffset,
+            consumedScroll = consumedScroll,
+            measureResult = layout(layoutWidth, layoutHeight) {
+                positionedItems.fastForEach { it.place(this) }
+            },
+            viewportStartOffset = -beforeContentPadding,
+            viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
+            visibleItemsInfo = positionedItems,
+            totalItemsCount = itemsCount,
+            reverseLayout = reverseLayout,
+            orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
+            afterContentPadding = afterContentPadding
+        )
+    }
+}
+
+/**
+ * Calculates [LazyMeasuredLine]s offsets.
+ */
+private fun calculateItemsOffsets(
+    lines: List<LazyMeasuredLine>,
+    layoutWidth: Int,
+    layoutHeight: Int,
+    finalMainAxisOffset: Int,
+    maxOffset: Int,
+    firstLineScrollOffset: Int,
+    isVertical: Boolean,
+    verticalArrangement: Arrangement.Vertical?,
+    horizontalArrangement: Arrangement.Horizontal?,
+    reverseLayout: Boolean,
+    density: Density,
+): MutableList<TvLazyGridPositionedItem> {
+    val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+    val hasSpareSpace = finalMainAxisOffset < min(mainAxisLayoutSize, maxOffset)
+    if (hasSpareSpace) {
+        check(firstLineScrollOffset == 0)
+    }
+
+    val positionedItems = ArrayList<TvLazyGridPositionedItem>(lines.fastSumBy { it.items.size })
+
+    if (hasSpareSpace) {
+        val linesCount = lines.size
+        fun Int.reverseAware() =
+            if (!reverseLayout) this else linesCount - this - 1
+
+        val sizes = IntArray(linesCount) { index ->
+            lines[index.reverseAware()].mainAxisSize
+        }
+        val offsets = IntArray(linesCount) { 0 }
+        if (isVertical) {
+            with(requireNotNull(verticalArrangement)) {
+                density.arrange(mainAxisLayoutSize, sizes, offsets)
+            }
+        } else {
+            with(requireNotNull(horizontalArrangement)) {
+                // Enforces Ltr layout direction as it is mirrored with placeRelative later.
+                density.arrange(mainAxisLayoutSize, sizes, LayoutDirection.Ltr, offsets)
+            }
+        }
+
+        val reverseAwareOffsetIndices =
+            if (reverseLayout) offsets.indices.reversed() else offsets.indices
+
+        for (index in reverseAwareOffsetIndices) {
+            val absoluteOffset = offsets[index]
+            // when reverseLayout == true, offsets are stored in the reversed order to items
+            val line = lines[index.reverseAware()]
+            val relativeOffset = if (reverseLayout) {
+                // inverse offset to align with scroll direction for positioning
+                mainAxisLayoutSize - absoluteOffset - line.mainAxisSize
+            } else {
+                absoluteOffset
+            }
+            positionedItems.addAll(
+                line.position(relativeOffset, layoutWidth, layoutHeight)
+            )
+        }
+    } else {
+        var currentMainAxis = firstLineScrollOffset
+        lines.fastForEach {
+            positionedItems.addAll(it.position(currentMainAxis, layoutWidth, layoutHeight))
+            currentMainAxis += it.mainAxisSizeWithSpacings
+        }
+    }
+    return positionedItems
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt
new file mode 100644
index 0000000..c7a6165
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+
+/**
+ * Contains the current scroll position represented by the first visible item index and the first
+ * visible item scroll offset.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyGridScrollPosition(
+    initialIndex: Int = 0,
+    initialScrollOffset: Int = 0
+) {
+    var index by mutableStateOf(ItemIndex(initialIndex))
+        private set
+
+    var scrollOffset by mutableStateOf(initialScrollOffset)
+        private set
+
+    private var hadFirstNotEmptyLayout = false
+
+    /** The last known key of the first item at [index] line. */
+    private var lastKnownFirstItemKey: Any? = null
+
+    /**
+     * Updates the current scroll position based on the results of the last measurement.
+     */
+    fun updateFromMeasureResult(measureResult: TvLazyGridMeasureResult) {
+        lastKnownFirstItemKey = measureResult.firstVisibleLine?.items?.firstOrNull()?.key
+        // we ignore the index and offset from measureResult until we get at least one
+        // measurement with real items. otherwise the initial index and scroll passed to the
+        // state would be lost and overridden with zeros.
+        if (hadFirstNotEmptyLayout || measureResult.totalItemsCount > 0) {
+            hadFirstNotEmptyLayout = true
+            val scrollOffset = measureResult.firstVisibleLineScrollOffset
+            check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" }
+            Snapshot.withoutReadObservation {
+                update(
+                    ItemIndex(
+                        measureResult.firstVisibleLine?.items?.firstOrNull()?.index?.value ?: 0
+                    ),
+                    scrollOffset
+                )
+            }
+        }
+    }
+
+    /**
+     * Updates the scroll position - the passed values will be used as a start position for
+     * composing the items during the next measure pass and will be updated by the real
+     * position calculated during the measurement. This means that there is guarantee that
+     * exactly this index and offset will be applied as it is possible that:
+     * a) there will be no item at this index in reality
+     * b) item at this index will be smaller than the asked scrollOffset, which means we would
+     * switch to the next item
+     * c) there will be not enough items to fill the viewport after the requested index, so we
+     * would have to compose few elements before the asked index, changing the first visible item.
+     */
+    fun requestPosition(index: ItemIndex, scrollOffset: Int) {
+        update(index, scrollOffset)
+        // clear the stored key as we have a direct request to scroll to [index] position and the
+        // next [checkIfFirstVisibleItemWasMoved] shouldn't override this.
+        lastKnownFirstItemKey = null
+    }
+
+    /**
+     * In addition to keeping the first visible item index we also store the key of this item.
+     * When the user provided custom keys for the items this mechanism allows us to detect when
+     * there were items added or removed before our current first visible item and keep this item
+     * as the first visible one even given that its index has been changed.
+     */
+    fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyGridItemProvider) {
+        Snapshot.withoutReadObservation {
+            update(findLazyGridIndexByKey(lastKnownFirstItemKey, index, itemProvider), scrollOffset)
+        }
+    }
+
+    private fun update(index: ItemIndex, scrollOffset: Int) {
+        require(index.value >= 0f) { "Index should be non-negative (${index.value})" }
+        if (index != this.index) {
+            this.index = index
+        }
+        if (scrollOffset != this.scrollOffset) {
+            this.scrollOffset = scrollOffset
+        }
+    }
+
+    private companion object {
+        /**
+         * Finds a position of the item with the given key in the grid. This logic allows us to
+         * detect when there were items added or removed before our current first item.
+         */
+        private fun findLazyGridIndexByKey(
+            key: Any?,
+            lastKnownIndex: ItemIndex,
+            itemProvider: LazyGridItemProvider
+        ): ItemIndex {
+            if (key == null) {
+                // there were no real item during the previous measure
+                return lastKnownIndex
+            }
+            if (lastKnownIndex.value < itemProvider.itemCount &&
+                key == itemProvider.getKey(lastKnownIndex.value)
+            ) {
+                // this item is still at the same index
+                return lastKnownIndex
+            }
+            val newIndex = itemProvider.keyToIndexMap[key]
+            if (newIndex != null) {
+                return ItemIndex(newIndex)
+            }
+            // fallback to the previous index if we don't know the new index of the item
+            return lastKnownIndex
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrolling.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrolling.kt
new file mode 100644
index 0000000..20bbd68
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrolling.kt
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.animateTo
+import androidx.compose.animation.core.copy
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastFirstOrNull
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.abs
+import kotlin.math.max
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+private class ItemFoundInScroll(
+    val item: TvLazyGridItemInfo,
+    val previousAnimation: AnimationState<Float, AnimationVector1D>
+) : CancellationException()
+
+private val TargetDistance = 2500.dp
+private val BoundDistance = 1500.dp
+
+private const val DEBUG = false
+private inline fun debugLog(generateMsg: () -> String) {
+    if (DEBUG) {
+        println("LazyGridScrolling: ${generateMsg()}")
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+internal suspend fun TvLazyGridState.doSmoothScrollToItem(
+    index: Int,
+    scrollOffset: Int,
+    slotsPerLine: Int
+) {
+    require(index >= 0f) { "Index should be non-negative ($index)" }
+    fun getTargetItem() = layoutInfo.visibleItemsInfo.fastFirstOrNull {
+        it.index == index
+    }
+    scroll {
+        try {
+            val targetDistancePx = with(density) { TargetDistance.toPx() }
+            val boundDistancePx = with(density) { BoundDistance.toPx() }
+            var loop = true
+            var anim = AnimationState(0f)
+            val targetItemInitialInfo = getTargetItem()
+            if (targetItemInitialInfo != null) {
+                // It's already visible, just animate directly
+                throw ItemFoundInScroll(
+                    targetItemInitialInfo,
+                    anim
+                )
+            }
+            val forward = index > firstVisibleItemIndex
+
+            fun isOvershot(): Boolean {
+                // Did we scroll past the item?
+                @Suppress("RedundantIf") // It's way easier to understand the logic this way
+                return if (forward) {
+                    if (firstVisibleItemIndex > index) {
+                        true
+                    } else if (
+                        firstVisibleItemIndex == index &&
+                        firstVisibleItemScrollOffset > scrollOffset
+                    ) {
+                        true
+                    } else {
+                        false
+                    }
+                } else { // backward
+                    if (firstVisibleItemIndex < index) {
+                        true
+                    } else if (
+                        firstVisibleItemIndex == index &&
+                        firstVisibleItemScrollOffset < scrollOffset
+                    ) {
+                        true
+                    } else {
+                        false
+                    }
+                }
+            }
+
+            var loops = 1
+            while (loop && layoutInfo.totalItemsCount > 0) {
+                val visibleItems = layoutInfo.visibleItemsInfo
+                val averageLineMainAxisSize = calculateLineAverageMainAxisSize(
+                    visibleItems,
+                    true // TODO(b/191238807)
+                )
+                val before = index < firstVisibleItemIndex
+                val linesDiff =
+                    (index - firstVisibleItemIndex + (slotsPerLine - 1) * if (before) -1 else 1) /
+                        slotsPerLine
+
+                val expectedDistance = (averageLineMainAxisSize * linesDiff).toFloat() +
+                    scrollOffset - firstVisibleItemScrollOffset
+                val target = if (abs(expectedDistance) < targetDistancePx) {
+                    expectedDistance
+                } else {
+                    if (forward) targetDistancePx else -targetDistancePx
+                }
+
+                debugLog {
+                    "Scrolling to index=$index offset=$scrollOffset from " +
+                        "index=$firstVisibleItemIndex offset=$firstVisibleItemScrollOffset with " +
+                        "averageSize=$averageLineMainAxisSize and calculated target=$target"
+                }
+
+                anim = anim.copy(value = 0f)
+                var prevValue = 0f
+                anim.animateTo(
+                    target,
+                    sequentialAnimation = (anim.velocity != 0f)
+                ) {
+                    // If we haven't found the item yet, check if it's visible.
+                    var targetItem = getTargetItem()
+
+                    if (targetItem == null) {
+                        // Springs can overshoot their target, clamp to the desired range
+                        val coercedValue = if (target > 0) {
+                            value.coerceAtMost(target)
+                        } else {
+                            value.coerceAtLeast(target)
+                        }
+                        val delta = coercedValue - prevValue
+                        debugLog {
+                            "Scrolling by $delta (target: $target, coercedValue: $coercedValue)"
+                        }
+
+                        val consumed = scrollBy(delta)
+                        targetItem = getTargetItem()
+                        if (targetItem != null) {
+                            debugLog { "Found the item after performing scrollBy()" }
+                        } else if (!isOvershot()) {
+                            if (delta != consumed) {
+                                debugLog { "Hit end without finding the item" }
+                                cancelAnimation()
+                                loop = false
+                                return@animateTo
+                            }
+                            prevValue += delta
+                            if (forward) {
+                                if (value > boundDistancePx) {
+                                    debugLog { "Struck bound going forward" }
+                                    cancelAnimation()
+                                }
+                            } else {
+                                if (value < -boundDistancePx) {
+                                    debugLog { "Struck bound going backward" }
+                                    cancelAnimation()
+                                }
+                            }
+
+                            // Magic constants for teleportation chosen arbitrarily by experiment
+                            if (forward) {
+                                if (
+                                    loops >= 2 &&
+                                    index - layoutInfo.visibleItemsInfo.last().index > 200
+                                ) {
+                                    // Teleport
+                                    debugLog { "Teleport forward" }
+                                    snapToItemIndexInternal(index = index - 200, scrollOffset = 0)
+                                }
+                            } else {
+                                if (
+                                    loops >= 2 &&
+                                    layoutInfo.visibleItemsInfo.first().index - index > 100
+                                ) {
+                                    // Teleport
+                                    debugLog { "Teleport backward" }
+                                    snapToItemIndexInternal(index = index + 200, scrollOffset = 0)
+                                }
+                            }
+                        }
+                    }
+
+                    // We don't throw ItemFoundInScroll when we snap, because once we've snapped to
+                    // the final position, there's no need to animate to it.
+                    if (isOvershot()) {
+                        debugLog { "Overshot" }
+                        snapToItemIndexInternal(index = index, scrollOffset = scrollOffset)
+                        loop = false
+                        cancelAnimation()
+                        return@animateTo
+                    } else if (targetItem != null) {
+                        debugLog { "Found item" }
+                        throw ItemFoundInScroll(
+                            targetItem,
+                            anim
+                        )
+                    }
+                }
+
+                loops++
+            }
+        } catch (itemFound: ItemFoundInScroll) {
+            // We found it, animate to it
+            // Bring to the requested position - will be automatically stopped if not possible
+            val anim = itemFound.previousAnimation.copy(value = 0f)
+            // TODO(b/191238807)
+            val target = (itemFound.item.offset.y + scrollOffset).toFloat()
+            var prevValue = 0f
+            debugLog {
+                "Seeking by $target at velocity ${itemFound.previousAnimation.velocity}"
+            }
+            anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) {
+                // Springs can overshoot their target, clamp to the desired range
+                val coercedValue = when {
+                    target > 0 -> {
+                        value.coerceAtMost(target)
+                    }
+                    target < 0 -> {
+                        value.coerceAtLeast(target)
+                    }
+                    else -> {
+                        debugLog { "WARNING: somehow ended up seeking 0px, this shouldn't happen" }
+                        0f
+                    }
+                }
+                val delta = coercedValue - prevValue
+                debugLog { "Seeking by $delta (coercedValue = $coercedValue)" }
+                val consumed = scrollBy(delta)
+                if (delta != consumed /* hit the end, stop */ ||
+                    coercedValue != value /* would have overshot, stop */
+                ) {
+                    cancelAnimation()
+                }
+                prevValue += delta
+            }
+            // Once we're finished the animation, snap to the exact position to account for
+            // rounding error (otherwise we tend to end up with the previous item scrolled the
+            // tiniest bit onscreen)
+            // TODO: prevent temporarily scrolling *past* the item
+            snapToItemIndexInternal(index = index, scrollOffset = scrollOffset)
+        }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+private fun calculateLineAverageMainAxisSize(
+    visibleItems: List<TvLazyGridItemInfo>,
+    isVertical: Boolean
+): Int {
+    val lineOf: (Int) -> Int = {
+        if (isVertical) visibleItems[it].row else visibleItems[it].column
+    }
+
+    var totalLinesMainAxisSize = 0
+    var linesCount = 0
+
+    var lineStartIndex = 0
+    while (lineStartIndex < visibleItems.size) {
+        val currentLine = lineOf(lineStartIndex)
+        if (currentLine == -1) {
+            // Filter out exiting items.
+            ++lineStartIndex
+            continue
+        }
+
+        var lineMainAxisSize = 0
+        var lineEndIndex = lineStartIndex
+        while (lineEndIndex < visibleItems.size && lineOf(lineEndIndex) == currentLine) {
+            lineMainAxisSize = max(
+                lineMainAxisSize,
+                if (isVertical) {
+                    visibleItems[lineEndIndex].size.height
+                } else {
+                    visibleItems[lineEndIndex].size.width
+                }
+            )
+            ++lineEndIndex
+        }
+
+        totalLinesMainAxisSize += lineMainAxisSize
+        ++linesCount
+
+        lineStartIndex = lineEndIndex
+    }
+
+    return totalLinesMainAxisSize / linesCount
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpan.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpan.kt
new file mode 100644
index 0000000..73e06cc
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpan.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.Immutable
+
+/**
+ * Represents the span of an item in a [TvLazyVerticalGrid].
+ */
+@Immutable
+@kotlin.jvm.JvmInline
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+value class TvGridItemSpan internal constructor(private val packedValue: Long) {
+    /**
+     * The span of the item on the current line. This will be the horizontal span for items of
+     * [TvLazyVerticalGrid].
+     */
+    @ExperimentalFoundationApi
+    val currentLineSpan: Int get() = packedValue.toInt()
+}
+
+/**
+ * Creates a [TvGridItemSpan] with a specified [currentLineSpan]. This will be the horizontal span
+ * for an item of a [TvLazyVerticalGrid].
+ */
+fun TvGridItemSpan(currentLineSpan: Int) = TvGridItemSpan(currentLineSpan.toLong())
+
+/**
+ * Scope of lambdas used to calculate the spans of items in lazy grids.
+ */
+@TvLazyGridScopeMarker
+sealed interface TvLazyGridItemSpanScope {
+    /**
+     * The max current line (horizontal for vertical grids) the item can occupy, such that
+     * it will be positioned on the current line.
+     *
+     * For example if [TvLazyVerticalGrid] has 3 columns this value will be 3 for the first cell in
+     * the line, 2 for the second cell, and 1 for the last one. If you return a span count larger
+     * than [maxCurrentLineSpan] this means we can't fit this cell into the current line, so the
+     * cell will be positioned on the next line.
+     */
+    val maxCurrentLineSpan: Int
+
+    /**
+     * The max line span (horizontal for vertical grids) an item can occupy. This will be the
+     * number of columns in vertical grids or the number of rows in horizontal grids.
+     *
+     * For example if [TvLazyVerticalGrid] has 3 columns this value will be 3 for each cell.
+     */
+    val maxLineSpan: Int
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
new file mode 100644
index 0000000..e72ee1e
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import kotlin.math.min
+import kotlin.math.sqrt
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyGridSpanLayoutProvider(private val itemsSnapshot: LazyGridItemsSnapshot) {
+    class LineConfiguration(val firstItemIndex: Int, val spans: List<TvGridItemSpan>)
+
+    /** Caches the bucket info on lines 0, [bucketSize], 2 * [bucketSize], etc. */
+    private val buckets = ArrayList<Bucket>().apply { add(Bucket(0)) }
+    /**
+     * The interval at each we will store the starting element of lines. These will be then
+     * used to calculate the layout of arbitrary lines, by starting from the closest
+     * known "bucket start". The smaller the bucketSize, the smaller cost for calculating layout
+     * of arbitrary lines but the higher memory usage for [buckets].
+     */
+    private val bucketSize get() = sqrt(1.0 * totalSize / slotsPerLine).toInt() + 1
+    /** Caches the last calculated line index, useful when scrolling in main axis direction. */
+    private var lastLineIndex = 0
+    /** Caches the starting item index on [lastLineIndex]. */
+    private var lastLineStartItemIndex = 0
+    /** Caches the span of [lastLineStartItemIndex], if this was already calculated. */
+    private var lastLineStartKnownSpan = 0
+    /**
+     * Caches a calculated bucket, this is useful when scrolling in reverse main axis
+     * direction. We cannot only keep the last element, as we would not know previous max span.
+     */
+    private var cachedBucketIndex = -1
+    /**
+     * Caches layout of [cachedBucketIndex], this is useful when scrolling in reverse main axis
+     * direction. We cannot only keep the last element, as we would not know previous max span.
+     */
+    private val cachedBucket = mutableListOf<Int>()
+    /**
+     * List of 1x1 spans if we do not have custom spans.
+     */
+    private var previousDefaultSpans = emptyList<TvGridItemSpan>()
+    private fun getDefaultSpans(currentSlotsPerLine: Int) =
+        if (currentSlotsPerLine == previousDefaultSpans.size) {
+            previousDefaultSpans
+        } else {
+            List(currentSlotsPerLine) { TvGridItemSpan(1) }.also { previousDefaultSpans = it }
+        }
+
+    val totalSize get() = itemsSnapshot.itemsCount
+
+    /** The number of slots on one grid line e.g. the number of columns of a vertical grid. */
+    var slotsPerLine = 0
+        set(value) {
+            if (value != field) {
+                field = value
+                invalidateCache()
+            }
+        }
+
+    fun getLineConfiguration(lineIndex: Int): LineConfiguration {
+        if (!itemsSnapshot.hasCustomSpans) {
+            // Quick return when all spans are 1x1 - in this case we can easily calculate positions.
+            val firstItemIndex = lineIndex * slotsPerLine
+            return LineConfiguration(
+                firstItemIndex,
+                getDefaultSpans(slotsPerLine.coerceAtMost(totalSize - firstItemIndex)
+                    .coerceAtLeast(0))
+            )
+        }
+
+        val bucketIndex = min(lineIndex / bucketSize, buckets.size - 1)
+        // We can calculate the items on the line from the closest cached bucket start item.
+        var currentLine = bucketIndex * bucketSize
+        var currentItemIndex = buckets[bucketIndex].firstItemIndex
+        var knownCurrentItemSpan = buckets[bucketIndex].firstItemKnownSpan
+        // ... but try using the more localised cached values.
+        if (lastLineIndex in currentLine..lineIndex) {
+            // The last calculated value is a better start point. Common when scrolling main axis.
+            currentLine = lastLineIndex
+            currentItemIndex = lastLineStartItemIndex
+            knownCurrentItemSpan = lastLineStartKnownSpan
+        } else if (bucketIndex == cachedBucketIndex &&
+            lineIndex - currentLine < cachedBucket.size
+        ) {
+            // It happens that the needed line start is fully cached. Common when scrolling in
+            // reverse main axis, as we decided to cacheThisBucket previously.
+            currentItemIndex = cachedBucket[lineIndex - currentLine]
+            currentLine = lineIndex
+            knownCurrentItemSpan = 0
+        }
+
+        val cacheThisBucket = currentLine % bucketSize == 0 &&
+            lineIndex - currentLine in 2 until bucketSize
+        if (cacheThisBucket) {
+            cachedBucketIndex = bucketIndex
+            cachedBucket.clear()
+        }
+
+        check(currentLine <= lineIndex)
+
+        while (currentLine < lineIndex && currentItemIndex < totalSize) {
+            if (cacheThisBucket) {
+                cachedBucket.add(currentItemIndex)
+            }
+
+            var spansUsed = 0
+            while (spansUsed < slotsPerLine && currentItemIndex < totalSize) {
+                val span = if (knownCurrentItemSpan == 0) {
+                    spanOf(currentItemIndex, slotsPerLine - spansUsed)
+                } else {
+                    knownCurrentItemSpan.also { knownCurrentItemSpan = 0 }
+                }
+                if (spansUsed + span > slotsPerLine) {
+                    knownCurrentItemSpan = span
+                    break
+                }
+
+                currentItemIndex++
+                spansUsed += span
+            }
+            ++currentLine
+            if (currentLine % bucketSize == 0 && currentItemIndex < totalSize) {
+                val currentLineBucket = currentLine / bucketSize
+                // This should happen, as otherwise this should have been used as starting point.
+                check(buckets.size == currentLineBucket)
+                buckets.add(Bucket(currentItemIndex, knownCurrentItemSpan))
+            }
+        }
+
+        lastLineIndex = lineIndex
+        lastLineStartItemIndex = currentItemIndex
+        lastLineStartKnownSpan = knownCurrentItemSpan
+
+        val firstItemIndex = currentItemIndex
+        val spans = mutableListOf<TvGridItemSpan>()
+
+        var spansUsed = 0
+        while (spansUsed < slotsPerLine && currentItemIndex < totalSize) {
+            val span = if (knownCurrentItemSpan == 0) {
+                spanOf(currentItemIndex, slotsPerLine - spansUsed)
+            } else {
+                knownCurrentItemSpan.also { knownCurrentItemSpan = 0 }
+            }
+            if (spansUsed + span > slotsPerLine) break
+
+            currentItemIndex++
+            spans.add(TvGridItemSpan(span))
+            spansUsed += span
+        }
+        return LineConfiguration(firstItemIndex, spans)
+    }
+
+    /**
+     * Calculate the line of index [itemIndex].
+     */
+    fun getLineIndexOfItem(itemIndex: Int): LineIndex {
+        if (totalSize <= 0) {
+            return LineIndex(0)
+        }
+        require(itemIndex < totalSize)
+        if (!itemsSnapshot.hasCustomSpans) {
+            return LineIndex(itemIndex / slotsPerLine)
+        }
+
+        val lowerBoundBucket = buckets.binarySearch { it.firstItemIndex - itemIndex }.let {
+            if (it >= 0) it else -it - 2
+        }
+        var currentLine = lowerBoundBucket * bucketSize
+        var currentItemIndex = buckets[lowerBoundBucket].firstItemIndex
+
+        require(currentItemIndex <= itemIndex)
+        var spansUsed = 0
+        while (currentItemIndex < itemIndex) {
+            val span = spanOf(currentItemIndex++, slotsPerLine - spansUsed)
+            if (spansUsed + span < slotsPerLine) {
+                spansUsed += span
+            } else if (spansUsed + span == slotsPerLine) {
+                ++currentLine
+                spansUsed = 0
+            } else {
+                // spansUsed + span > slotsPerLine
+                ++currentLine
+                spansUsed = span
+            }
+            if (currentLine % bucketSize == 0) {
+                val currentLineBucket = currentLine / bucketSize
+                if (currentLineBucket >= buckets.size) {
+                    buckets.add(Bucket(currentItemIndex - if (spansUsed > 0) 1 else 0))
+                }
+            }
+        }
+        if (spansUsed + spanOf(itemIndex, slotsPerLine - spansUsed) > slotsPerLine) {
+            ++currentLine
+        }
+
+        return LineIndex(currentLine)
+    }
+
+    private fun spanOf(itemIndex: Int, maxSpan: Int) = with(itemsSnapshot) {
+        with(TvLazyGridItemSpanScopeImpl) {
+            maxCurrentLineSpan = maxSpan
+            maxLineSpan = slotsPerLine
+
+            getSpan(itemIndex).currentLineSpan.coerceIn(1, slotsPerLine)
+        }
+    }
+
+    private fun invalidateCache() {
+        buckets.clear()
+        buckets.add(Bucket(0))
+        lastLineIndex = 0
+        lastLineStartItemIndex = 0
+        cachedBucketIndex = -1
+        cachedBucket.clear()
+    }
+
+    private class Bucket(
+        /** Index of the first item in the bucket */
+        val firstItemIndex: Int,
+        /** Known span of the first item. Not zero only if this item caused "line break". */
+        val firstItemKnownSpan: Int = 0
+    )
+
+    private object TvLazyGridItemSpanScopeImpl : TvLazyGridItemSpanScope {
+        override var maxCurrentLineSpan = 0
+        override var maxLineSpan = 0
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt
new file mode 100644
index 0000000..9041d51e
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt
@@ -0,0 +1,228 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * Represents one measured item of the lazy grid. It can in fact consist of multiple placeables
+ * if the user emit multiple layout nodes in the item callback.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyMeasuredItem(
+    val index: ItemIndex,
+    val key: Any,
+    private val isVertical: Boolean,
+    /**
+     * Cross axis size is the same for all [placeables]. Take it as parameter for the case when
+     * [placeables] is empty.
+     */
+    val crossAxisSize: Int,
+    val mainAxisSpacing: Int,
+    private val reverseLayout: Boolean,
+    private val layoutDirection: LayoutDirection,
+    private val beforeContentPadding: Int,
+    private val afterContentPadding: Int,
+    val placeables: Array<Placeable>,
+    private val placementAnimator: LazyGridItemPlacementAnimator,
+    /**
+     * The offset which shouldn't affect any calculations but needs to be applied for the final
+     * value passed into the place() call.
+     */
+    private val visualOffset: IntOffset
+) {
+    /**
+     * Main axis size of the item - the max main axis size of the placeables.
+     */
+    val mainAxisSize: Int
+
+    /**
+     * The max main axis size of the placeables plus mainAxisSpacing.
+     */
+    val mainAxisSizeWithSpacings: Int
+
+    init {
+        var maxMainAxis = 0
+        placeables.forEach {
+            maxMainAxis = maxOf(maxMainAxis, if (isVertical) it.height else it.width)
+        }
+        mainAxisSize = maxMainAxis
+        mainAxisSizeWithSpacings = maxMainAxis + mainAxisSpacing
+    }
+
+    /**
+     * Calculates positions for the inner placeables at [rawCrossAxisOffset], [rawCrossAxisOffset].
+     * [layoutWidth] and [layoutHeight] should be provided to not place placeables which are ended
+     * up outside of the viewport (for example one item consist of 2 placeables, and the first one
+     * is not going to be visible, so we don't place it as an optimization, but place the second
+     * one). If [reverseOrder] is true the inner placeables would be placed in the inverted order.
+     */
+    fun position(
+        rawMainAxisOffset: Int,
+        rawCrossAxisOffset: Int,
+        layoutWidth: Int,
+        layoutHeight: Int,
+        row: Int,
+        column: Int,
+        lineMainAxisSize: Int
+    ): TvLazyGridPositionedItem {
+        val wrappers = mutableListOf<LazyGridPlaceableWrapper>()
+
+        val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+        val mainAxisOffset = if (reverseLayout) {
+            mainAxisLayoutSize - rawMainAxisOffset - mainAxisSize
+        } else {
+            rawMainAxisOffset
+        }
+        val crossAxisLayoutSize = if (isVertical) layoutWidth else layoutHeight
+        val crossAxisOffset = if (isVertical && layoutDirection == LayoutDirection.Rtl) {
+            crossAxisLayoutSize - rawCrossAxisOffset - crossAxisSize
+        } else {
+            rawCrossAxisOffset
+        }
+        val placeableOffset = if (isVertical) {
+            IntOffset(crossAxisOffset, mainAxisOffset)
+        } else {
+            IntOffset(mainAxisOffset, crossAxisOffset)
+        }
+
+        var placeableIndex = if (reverseLayout) placeables.lastIndex else 0
+        while (if (reverseLayout) placeableIndex >= 0 else placeableIndex < placeables.size) {
+            val it = placeables[placeableIndex]
+            val addIndex = if (reverseLayout) 0 else wrappers.size
+            wrappers.add(
+                addIndex,
+                LazyGridPlaceableWrapper(placeableOffset, it, placeables[placeableIndex].parentData)
+            )
+            if (reverseLayout) placeableIndex-- else placeableIndex++
+        }
+
+        return TvLazyGridPositionedItem(
+            offset = if (isVertical) {
+                IntOffset(rawCrossAxisOffset, rawMainAxisOffset)
+            } else {
+                IntOffset(rawMainAxisOffset, rawCrossAxisOffset)
+            },
+            placeableOffset = placeableOffset,
+            index = index.value,
+            key = key,
+            row = row,
+            column = column,
+            size = if (isVertical) {
+                IntSize(crossAxisSize, mainAxisSize)
+            } else {
+                IntSize(mainAxisSize, crossAxisSize)
+            },
+            lineMainAxisSize = lineMainAxisSize,
+            mainAxisSpacing = mainAxisSpacing,
+            minMainAxisOffset = -if (!reverseLayout) {
+                beforeContentPadding
+            } else {
+                afterContentPadding
+            },
+            maxMainAxisOffset = mainAxisLayoutSize +
+                if (!reverseLayout) afterContentPadding else beforeContentPadding,
+            isVertical = isVertical,
+            wrappers = wrappers,
+            placementAnimator = placementAnimator,
+            visualOffset = visualOffset
+        )
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+internal class TvLazyGridPositionedItem(
+    override val offset: IntOffset,
+    val placeableOffset: IntOffset,
+    override val index: Int,
+    override val key: Any,
+    override val row: Int,
+    override val column: Int,
+    override val size: IntSize,
+    val lineMainAxisSize: Int,
+    private val mainAxisSpacing: Int,
+    private val minMainAxisOffset: Int,
+    private val maxMainAxisOffset: Int,
+    private val isVertical: Boolean,
+    private val wrappers: List<LazyGridPlaceableWrapper>,
+    private val placementAnimator: LazyGridItemPlacementAnimator,
+    private val visualOffset: IntOffset
+) : TvLazyGridItemInfo {
+    val placeablesCount: Int get() = wrappers.size
+
+    val mainAxisSizeWithSpacings: Int get() =
+        mainAxisSpacing + if (isVertical) size.height else size.width
+
+    val lineMainAxisSizeWithSpacings: Int get() = mainAxisSpacing + lineMainAxisSize
+
+    fun getMainAxisSize(index: Int) = wrappers[index].placeable.mainAxisSize
+
+    fun getCrossAxisSize() = if (isVertical) size.width else size.height
+
+    fun getCrossAxisOffset() = if (isVertical) offset.x else offset.y
+
+    @Suppress("UNCHECKED_CAST")
+    fun getAnimationSpec(index: Int) =
+        wrappers[index].parentData as? FiniteAnimationSpec<IntOffset>?
+
+    val hasAnimations = run {
+        repeat(placeablesCount) { index ->
+            if (getAnimationSpec(index) != null) {
+                return@run true
+            }
+        }
+        false
+    }
+
+    fun place(
+        scope: Placeable.PlacementScope,
+    ) = with(scope) {
+        repeat(placeablesCount) { index ->
+            val placeable = wrappers[index].placeable
+            val minOffset = minMainAxisOffset - placeable.mainAxisSize
+            val maxOffset = maxMainAxisOffset
+            val offset = if (getAnimationSpec(index) != null) {
+                placementAnimator.getAnimatedOffset(
+                    key, index, minOffset, maxOffset, placeableOffset
+                )
+            } else {
+                placeableOffset
+            }
+            if (offset.mainAxis > minOffset && offset.mainAxis < maxOffset) {
+                if (isVertical) {
+                    placeable.placeWithLayer(offset + visualOffset)
+                } else {
+                    placeable.placeRelativeWithLayer(offset + visualOffset)
+                }
+            }
+        }
+    }
+
+    private val IntOffset.mainAxis get() = if (isVertical) y else x
+    private val Placeable.mainAxisSize get() = if (isVertical) height else width
+}
+
+internal class LazyGridPlaceableWrapper(
+    val offset: IntOffset,
+    val placeable: Placeable,
+    val parentData: Any?
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt
new file mode 100644
index 0000000..5ba2016
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+
+/**
+ * Abstracts away the subcomposition from the measuring logic.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyMeasuredItemProvider @ExperimentalFoundationApi constructor(
+    private val itemProvider: LazyGridItemProvider,
+    private val measureScope: LazyLayoutMeasureScope,
+    private val defaultMainAxisSpacing: Int,
+    private val measuredItemFactory: MeasuredItemFactory
+) {
+    /**
+     * Used to subcompose individual items of lazy grids. Composed placeables will be measured
+     * with the provided [constraints] and wrapped into [LazyMeasuredItem].
+     */
+    fun getAndMeasure(
+        index: ItemIndex,
+        mainAxisSpacing: Int = defaultMainAxisSpacing,
+        constraints: Constraints
+    ): LazyMeasuredItem {
+        val key = itemProvider.getKey(index.value)
+        val placeables = measureScope.measure(index.value, constraints)
+        val crossAxisSize = if (constraints.hasFixedWidth) {
+            constraints.minWidth
+        } else {
+            require(constraints.hasFixedHeight)
+            constraints.minHeight
+        }
+        return measuredItemFactory.createItem(
+            index,
+            key,
+            crossAxisSize,
+            mainAxisSpacing,
+            placeables
+        )
+    }
+
+    /**
+     * Contains the mapping between the key and the index. It could contain not all the items of
+     * the list as an optimization.
+     **/
+    val keyToIndexMap: Map<Any, Int> get() = itemProvider.keyToIndexMap
+}
+
+// This interface allows to avoid autoboxing on index param
+internal fun interface MeasuredItemFactory {
+    fun createItem(
+        index: ItemIndex,
+        key: Any,
+        crossAxisSize: Int,
+        mainAxisSpacing: Int,
+        placeables: Array<Placeable>
+    ): LazyMeasuredItem
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt
new file mode 100644
index 0000000..5310da9
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * Represents one measured line of the lazy list. Each item on the line can in fact consist of
+ * multiple placeables if the user emit multiple layout nodes in the item callback.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyMeasuredLine constructor(
+    val index: LineIndex,
+    val items: Array<LazyMeasuredItem>,
+    private val spans: List<TvGridItemSpan>,
+    private val isVertical: Boolean,
+    private val slotsPerLine: Int,
+    private val layoutDirection: LayoutDirection,
+    /**
+     * Spacing to be added after [mainAxisSize], in the main axis direction.
+     */
+    private val mainAxisSpacing: Int,
+    private val crossAxisSpacing: Int
+) {
+    /**
+     * Main axis size of the line - the max main axis size of the items on the line.
+     */
+    val mainAxisSize: Int
+
+    /**
+     * Sum of [mainAxisSpacing] and the max of the main axis sizes of the placeables on the line.
+     */
+    val mainAxisSizeWithSpacings: Int
+
+    init {
+        var maxMainAxis = 0
+        items.forEach { item ->
+            maxMainAxis = maxOf(maxMainAxis, item.mainAxisSize)
+        }
+        mainAxisSize = maxMainAxis
+        mainAxisSizeWithSpacings = mainAxisSize + mainAxisSpacing
+    }
+
+    /**
+     * Whether this line contains any items.
+     */
+    fun isEmpty() = items.isEmpty()
+
+    /**
+     * Calculates positions for the [items] at [offset] main axis position.
+     * If [reverseOrder] is true the [items] would be placed in the inverted order.
+     */
+    fun position(
+        offset: Int,
+        layoutWidth: Int,
+        layoutHeight: Int
+    ): List<TvLazyGridPositionedItem> {
+        var usedCrossAxis = 0
+        var usedSpan = 0
+        return items.mapIndexed { itemIndex, item ->
+            val span = spans[itemIndex].currentLineSpan
+            val startSlot = if (layoutDirection == LayoutDirection.Rtl) {
+                slotsPerLine - usedSpan - span
+            } else {
+                usedSpan
+            }
+
+            item.position(
+                rawMainAxisOffset = offset,
+                rawCrossAxisOffset = usedCrossAxis,
+                layoutWidth = layoutWidth,
+                layoutHeight = layoutHeight,
+                row = if (isVertical) index.value else startSlot,
+                column = if (isVertical) startSlot else index.value,
+                lineMainAxisSize = mainAxisSize
+            ).also {
+                usedCrossAxis += item.crossAxisSize + crossAxisSpacing
+                usedSpan += span
+            }
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
new file mode 100644
index 0000000..5cf56f4
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.ui.unit.Constraints
+
+/**
+ * Abstracts away subcomposition and span calculation from the measuring logic of entire lines.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyMeasuredLineProvider(
+    private val isVertical: Boolean,
+    slotSizesSums: List<Int>,
+    crossAxisSpacing: Int,
+    private val gridItemsCount: Int,
+    private val spaceBetweenLines: Int,
+    private val measuredItemProvider: LazyMeasuredItemProvider,
+    private val spanLayoutProvider: LazyGridSpanLayoutProvider,
+    private val measuredLineFactory: MeasuredLineFactory
+) {
+    // The constraints for cross axis size. The main axis is not restricted.
+    internal val childConstraints: (startSlot: Int, span: Int) -> Constraints = { startSlot, span ->
+        val lastSlotSum = slotSizesSums[startSlot + span - 1]
+        val prevSlotSum = if (startSlot == 0) 0 else slotSizesSums[startSlot - 1]
+        val slotsSize = lastSlotSum - prevSlotSum
+        val crossAxisSize = slotsSize + crossAxisSpacing * (span - 1)
+        if (isVertical) {
+            Constraints.fixedWidth(crossAxisSize)
+        } else {
+            Constraints.fixedHeight(crossAxisSize)
+        }
+    }
+
+    /**
+     * Used to subcompose items on lines of lazy grids. Composed placeables will be measured
+     * with the correct constraints and wrapped into [LazyMeasuredLine].
+     */
+    fun getAndMeasure(lineIndex: LineIndex): LazyMeasuredLine {
+        val lineConfiguration = spanLayoutProvider.getLineConfiguration(lineIndex.value)
+        val lineItemsCount = lineConfiguration.spans.size
+
+        // we add space between lines as an extra spacing for all lines apart from the last one
+        // so the lazy grid measuring logic will take it into account.
+        val mainAxisSpacing = if (lineItemsCount == 0 ||
+            lineConfiguration.firstItemIndex + lineItemsCount == gridItemsCount) {
+            0
+        } else {
+            spaceBetweenLines
+        }
+
+        var startSlot = 0
+        val items = Array(lineItemsCount) {
+            val span = lineConfiguration.spans[it].currentLineSpan
+            val constraints = childConstraints(startSlot, span)
+            measuredItemProvider.getAndMeasure(
+                ItemIndex(lineConfiguration.firstItemIndex + it),
+                mainAxisSpacing,
+                constraints
+            ).also { startSlot += span }
+        }
+        return measuredLineFactory.createLine(
+            lineIndex,
+            items,
+            lineConfiguration.spans,
+            mainAxisSpacing
+        )
+    }
+
+    /**
+     * Contains the mapping between the key and the index. It could contain not all the items of
+     * the list as an optimization.
+     **/
+    val keyToIndexMap: Map<Any, Int> get() = measuredItemProvider.keyToIndexMap
+}
+
+// This interface allows to avoid autoboxing on index param
+@OptIn(ExperimentalFoundationApi::class)
+internal fun interface MeasuredLineFactory {
+    fun createLine(
+        index: LineIndex,
+        items: Array<LazyMeasuredItem>,
+        spans: List<TvGridItemSpan>,
+        mainAxisSpacing: Int
+    ): LazyMeasuredLine
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt
new file mode 100644
index 0000000..ea19b91
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.CollectionInfo
+import androidx.compose.ui.semantics.ScrollAxisRange
+import androidx.compose.ui.semantics.collectionInfo
+import androidx.compose.ui.semantics.horizontalScrollAxisRange
+import androidx.compose.ui.semantics.indexForKey
+import androidx.compose.ui.semantics.scrollBy
+import androidx.compose.ui.semantics.scrollToIndex
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.verticalScrollAxisRange
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalFoundationApi::class)
+@Suppress("ComposableModifierFactory", "ModifierInspectorInfo")
+@Composable
+internal fun Modifier.lazyGridSemantics(
+    itemProvider: LazyGridItemProvider,
+    state: TvLazyGridState,
+    coroutineScope: CoroutineScope,
+    isVertical: Boolean,
+    reverseScrolling: Boolean,
+    userScrollEnabled: Boolean
+) = this.then(
+    remember(
+        itemProvider,
+        state,
+        isVertical,
+        reverseScrolling,
+        userScrollEnabled
+    ) {
+        val indexForKeyMapping: (Any) -> Int = { needle ->
+            val key = itemProvider::getKey
+            var result = -1
+            for (index in 0 until itemProvider.itemCount) {
+                if (key(index) == needle) {
+                    result = index
+                    break
+                }
+            }
+            result
+        }
+
+        val accessibilityScrollState = ScrollAxisRange(
+            value = {
+                // This is a simple way of representing the current position without
+                // needing any lazy items to be measured. It's good enough so far, because
+                // screen-readers care mostly about whether scroll position changed or not
+                // rather than the actual offset in pixels.
+                state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+            },
+            maxValue = {
+                if (state.canScrollForward) {
+                    // If we can scroll further, we don't know the end yet,
+                    // but it's upper bounded by #items + 1
+                    itemProvider.itemCount + 1f
+                } else {
+                    // If we can't scroll further, the current value is the max
+                    state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+                }
+            },
+            reverseScrolling = reverseScrolling
+        )
+
+        val scrollByAction: ((x: Float, y: Float) -> Boolean)? = if (userScrollEnabled) {
+            { x, y ->
+                val delta = if (isVertical) {
+                    y
+                } else {
+                    x
+                }
+                coroutineScope.launch {
+                    (state as ScrollableState).animateScrollBy(delta)
+                }
+                // TODO(aelias): is it important to return false if we know in advance we cannot scroll?
+                true
+            }
+        } else {
+            null
+        }
+
+        val scrollToIndexAction: ((Int) -> Boolean)? = if (userScrollEnabled) {
+            { index ->
+                require(index >= 0 && index < state.layoutInfo.totalItemsCount) {
+                    "Can't scroll to index $index, it is out of " +
+                        "bounds [0, ${state.layoutInfo.totalItemsCount})"
+                }
+                coroutineScope.launch {
+                    state.scrollToItem(index)
+                }
+                true
+            }
+        } else {
+            null
+        }
+
+        // TODO(popam): check if this is correct - it would be nice to provide correct columns here
+        val collectionInfo = CollectionInfo(rowCount = -1, columnCount = -1)
+
+        Modifier.semantics {
+            indexForKey(indexForKeyMapping)
+
+            if (isVertical) {
+                verticalScrollAxisRange = accessibilityScrollState
+            } else {
+                horizontalScrollAxisRange = accessibilityScrollState
+            }
+
+            if (scrollByAction != null) {
+                scrollBy(action = scrollByAction)
+            }
+
+            if (scrollToIndexAction != null) {
+                scrollToIndex(action = scrollToIndexAction)
+            }
+
+            this.collectionInfo = collectionInfo
+        }
+    }
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt
new file mode 100644
index 0000000..2bcedda
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * Contains useful information about an individual item in lazy grids like [TvLazyVerticalGrid].
+ *
+ * @see TvLazyGridLayoutInfo
+ */
+sealed interface TvLazyGridItemInfo {
+    /**
+     * The index of the item in the grid.
+     */
+    val index: Int
+
+    /**
+     * The key of the item which was passed to the item() or items() function.
+     */
+    val key: Any
+
+    /**
+     * The offset of the item in pixels. It is relative to the top start of the lazy grid container.
+     */
+    val offset: IntOffset
+
+    /**
+     * The row occupied by the top start point of the item.
+     * If this is unknown, for example while this item is animating to exit the viewport and is
+     * still visible, the value will be [UnknownRow].
+     */
+    val row: Int
+
+    /**
+     * The column occupied by the top start point of the item.
+     * If this is unknown, for example while this item is animating to exit the viewport and is
+     * still visible, the value will be [UnknownColumn].
+     */
+    val column: Int
+
+    /**
+     * The pixel size of the item. Note that if you emit multiple layouts in the composable
+     * slot for the item then this size will be calculated as the max of their sizes.
+     */
+    val size: IntSize
+
+    companion object {
+        /**
+         * Possible value for [row], when they are unknown. This can happen when the item is
+         * visible while animating to exit the viewport.
+         */
+        const val UnknownRow = -1
+        /**
+         * Possible value for [column], when they are unknown. This can happen when the item is
+         * visible while animating to exit the viewport.
+         */
+        const val UnknownColumn = -1
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScope.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScope.kt
new file mode 100644
index 0000000..5fae2451
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScope.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntOffset
+
+/**
+ * Receiver scope being used by the item content parameter of [TvLazyVerticalGrid].
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@Stable
+@TvLazyGridScopeMarker
+sealed interface TvLazyGridItemScope {
+    /**
+     * This modifier animates the item placement within the Lazy grid.
+     *
+     * When you provide a key via [TvLazyGridScope.item]/[TvLazyGridScope.items] this modifier will
+     * enable item reordering animations. Aside from item reordering all other position changes
+     * caused by events like arrangement or alignment changes will also be animated.
+     *
+     * @param animationSpec a finite animation that will be used to animate the item placement.
+     */
+    @ExperimentalFoundationApi
+    fun Modifier.animateItemPlacement(
+        animationSpec: FiniteAnimationSpec<IntOffset> = spring(
+            stiffness = Spring.StiffnessMediumLow,
+            visibilityThreshold = IntOffset.VisibilityThreshold
+        )
+    ): Modifier
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt
new file mode 100644
index 0000000..35d063f
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ParentDataModifier
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntOffset
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal object TvLazyGridItemScopeImpl : TvLazyGridItemScope {
+    @ExperimentalFoundationApi
+    override fun Modifier.animateItemPlacement(animationSpec: FiniteAnimationSpec<IntOffset>) =
+        this.then(AnimateItemPlacementModifier(animationSpec, debugInspectorInfo {
+            name = "animateItemPlacement"
+            value = animationSpec
+        }))
+}
+
+private class AnimateItemPlacementModifier(
+    val animationSpec: FiniteAnimationSpec<IntOffset>,
+    inspectorInfo: InspectorInfo.() -> Unit,
+) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {
+    override fun Density.modifyParentData(parentData: Any?): Any = animationSpec
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is AnimateItemPlacementModifier) return false
+        return animationSpec != other.animationSpec
+    }
+
+    override fun hashCode(): Int {
+        return animationSpec.hashCode()
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridLayoutInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridLayoutInfo.kt
new file mode 100644
index 0000000..6fc30dc
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridLayoutInfo.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * Contains useful information about the currently displayed layout state of lazy grids like
+ * [TvLazyVerticalGrid]. For example you can get the list of currently displayed items.
+ *
+ * Use [TvLazyGridState.layoutInfo] to retrieve this
+ */
+sealed interface TvLazyGridLayoutInfo {
+    /**
+     * The list of [TvLazyGridItemInfo] representing all the currently visible items.
+     */
+    val visibleItemsInfo: List<TvLazyGridItemInfo>
+
+    /**
+     * The start offset of the layout's viewport in pixels. You can think of it as a minimum offset
+     * which would be visible. Usually it is 0, but it can be negative if non-zero [beforeContentPadding]
+     * was applied as the content displayed in the content padding area is still visible.
+     *
+     * You can use it to understand what items from [visibleItemsInfo] are fully visible.
+     */
+    val viewportStartOffset: Int
+
+    /**
+     * The end offset of the layout's viewport in pixels. You can think of it as a maximum offset
+     * which would be visible. It is the size of the lazy grid layout minus [beforeContentPadding].
+     *
+     * You can use it to understand what items from [visibleItemsInfo] are fully visible.
+     */
+    val viewportEndOffset: Int
+
+    /**
+     * The total count of items passed to [TvLazyVerticalGrid].
+     */
+    val totalItemsCount: Int
+
+    /**
+     * The size of the viewport in pixels. It is the lazy grid layout size including all the
+     * content paddings.
+     */
+    val viewportSize: IntSize
+
+    /**
+     * The orientation of the lazy grid.
+     */
+    val orientation: Orientation
+
+    /**
+     * True if the direction of scrolling and layout is reversed.
+     */
+    val reverseLayout: Boolean
+
+    /**
+     * The content padding in pixels applied before the first row/column in the direction of scrolling.
+     * For example it is a top content padding for LazyVerticalGrid with reverseLayout set to false.
+     */
+    val beforeContentPadding: Int
+
+    /**
+     * The content padding in pixels applied after the last row/column in the direction of scrolling.
+     * For example it is a bottom content padding for LazyVerticalGrid with reverseLayout set to false.
+     */
+    val afterContentPadding: Int
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt
new file mode 100644
index 0000000..51bee04
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * The result of the measure pass for lazy list layout.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal class TvLazyGridMeasureResult(
+    // properties defining the scroll position:
+    /** The new first visible line of items.*/
+    val firstVisibleLine: LazyMeasuredLine?,
+    /** The new value for [TvLazyGridState.firstVisibleItemScrollOffset].*/
+    val firstVisibleLineScrollOffset: Int,
+    /** True if there is some space available to continue scrolling in the forward direction.*/
+    val canScrollForward: Boolean,
+    /** The amount of scroll consumed during the measure pass.*/
+    val consumedScroll: Float,
+    /** MeasureResult defining the layout.*/
+    measureResult: MeasureResult,
+    // properties representing the info needed for LazyListLayoutInfo:
+    /** see [TvLazyGridLayoutInfo.visibleItemsInfo] */
+    override val visibleItemsInfo: List<TvLazyGridItemInfo>,
+    /** see [TvLazyGridLayoutInfo.viewportStartOffset] */
+    override val viewportStartOffset: Int,
+    /** see [TvLazyGridLayoutInfo.viewportEndOffset] */
+    override val viewportEndOffset: Int,
+    /** see [TvLazyGridLayoutInfo.totalItemsCount] */
+    override val totalItemsCount: Int,
+    /** see [TvLazyGridLayoutInfo.reverseLayout] */
+    override val reverseLayout: Boolean,
+    /** see [TvLazyGridLayoutInfo.orientation] */
+    override val orientation: Orientation,
+    /** see [TvLazyGridLayoutInfo.afterContentPadding] */
+    override val afterContentPadding: Int
+) : TvLazyGridLayoutInfo, MeasureResult by measureResult {
+    override val viewportSize: IntSize
+        get() = IntSize(width, height)
+    override val beforeContentPadding: Int get() = -viewportStartOffset
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt
new file mode 100644
index 0000000..bfd2510
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.MutableIntervalList
+import androidx.compose.runtime.Composable
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class TvLazyGridScopeImpl : TvLazyGridScope {
+    internal val intervals = MutableIntervalList<LazyGridIntervalContent>()
+    internal var hasCustomSpans = false
+
+    private val DefaultSpan: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan = { TvGridItemSpan(1) }
+
+    override fun item(
+        key: Any?,
+        span: (TvLazyGridItemSpanScope.() -> TvGridItemSpan)?,
+        contentType: Any?,
+        content: @Composable TvLazyGridItemScope.() -> Unit
+    ) {
+        intervals.addInterval(
+            1,
+            LazyGridIntervalContent(
+                key = key?.let { { key } },
+                span = span?.let { { span() } } ?: DefaultSpan,
+                type = { contentType },
+                item = { content() }
+            )
+        )
+        if (span != null) hasCustomSpans = true
+    }
+
+    override fun items(
+        count: Int,
+        key: ((index: Int) -> Any)?,
+        span: (TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan)?,
+        contentType: (index: Int) -> Any?,
+        itemContent: @Composable TvLazyGridItemScope.(index: Int) -> Unit
+    ) {
+        intervals.addInterval(
+            count,
+            LazyGridIntervalContent(
+                key = key,
+                span = span ?: DefaultSpan,
+                type = contentType,
+                item = itemContent
+            )
+        )
+        if (span != null) hasCustomSpans = true
+    }
+}
+
+internal class LazyGridIntervalContent(
+    val key: ((index: Int) -> Any)?,
+    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,
+    val type: ((index: Int) -> Any?),
+    val item: @Composable TvLazyGridItemScope.(Int) -> Unit
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeMarker.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeMarker.kt
new file mode 100644
index 0000000..ed5b2bc
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeMarker.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.grid
+
+/**
+ * DSL marker used to distinguish between lazy grid dsl scope and the item content scope.
+ */
+@DslMarker
+annotation class TvLazyGridScopeMarker
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
new file mode 100644
index 0000000..df4e4c2
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
@@ -0,0 +1,414 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.grid
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.ScrollScope
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.collection.mutableVectorOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.layout.Remeasurement
+import androidx.compose.ui.layout.RemeasurementModifier
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.util.fastForEach
+import androidx.tv.foundation.lazy.list.AwaitFirstLayoutModifier
+import kotlin.math.abs
+
+/**
+ * Creates a [TvLazyGridState] that is remembered across compositions.
+ *
+ * Changes to the provided initial values will **not** result in the state being recreated or
+ * changed in any way if it has already been created.
+ *
+ * @param initialFirstVisibleItemIndex the initial value for [TvLazyGridState.firstVisibleItemIndex]
+ * @param initialFirstVisibleItemScrollOffset the initial value for
+ * [TvLazyGridState.firstVisibleItemScrollOffset]
+ */
+@Composable
+fun rememberTvLazyGridState(
+    initialFirstVisibleItemIndex: Int = 0,
+    initialFirstVisibleItemScrollOffset: Int = 0
+): TvLazyGridState {
+    return rememberSaveable(saver = TvLazyGridState.Saver) {
+        TvLazyGridState(
+            initialFirstVisibleItemIndex,
+            initialFirstVisibleItemScrollOffset
+        )
+    }
+}
+
+/**
+ * A state object that can be hoisted to control and observe scrolling.
+ *
+ * In most cases, this will be created via [rememberTvLazyGridState].
+ *
+ * @param firstVisibleItemIndex the initial value for [TvLazyGridState.firstVisibleItemIndex]
+ * @param firstVisibleItemScrollOffset the initial value for
+ * [TvLazyGridState.firstVisibleItemScrollOffset]
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Stable
+class TvLazyGridState constructor(
+    firstVisibleItemIndex: Int = 0,
+    firstVisibleItemScrollOffset: Int = 0
+) : ScrollableState {
+    /**
+     * The holder class for the current scroll position.
+     */
+    private val scrollPosition =
+        LazyGridScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)
+
+    /**
+     * The index of the first item that is visible.
+     *
+     * Note that this property is observable and if you use it in the composable function it will
+     * be recomposed on every change causing potential performance issues.
+     */
+    val firstVisibleItemIndex: Int get() = scrollPosition.index.value
+
+    /**
+     * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the
+     * amount that the item is offset backwards
+     */
+    val firstVisibleItemScrollOffset: Int get() = scrollPosition.scrollOffset
+
+    /** Backing state for [layoutInfo] */
+    private val layoutInfoState = mutableStateOf<TvLazyGridLayoutInfo>(EmptyTvLazyGridLayoutInfo)
+
+    /**
+     * The object of [TvLazyGridLayoutInfo] calculated during the last layout pass. For example,
+     * you can use it to calculate what items are currently visible.
+     *
+     * Note that this property is observable and is updated after every scroll or remeasure.
+     * If you use it in the composable function it will be recomposed on every change causing
+     * potential performance issues including infinity recomposition loop.
+     * Therefore, avoid using it in the composition.
+     */
+    val layoutInfo: TvLazyGridLayoutInfo get() = layoutInfoState.value
+
+    /**
+     * [InteractionSource] that will be used to dispatch drag events when this
+     * grid is being dragged. If you want to know whether the fling (or animated scroll) is in
+     * progress, use [isScrollInProgress].
+     */
+    val interactionSource: InteractionSource get() = internalInteractionSource
+
+    internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
+
+    /**
+     * The amount of scroll to be consumed in the next layout pass.  Scrolling forward is negative
+     * - that is, it is the amount that the items are offset in y
+     */
+    internal var scrollToBeConsumed = 0f
+        private set
+
+    /**
+     * Needed for [animateScrollToItem]. Updated on every measure.
+     */
+    internal var slotsPerLine: Int by mutableStateOf(0)
+
+    /**
+     * Needed for [animateScrollToItem]. Updated on every measure.
+     */
+    internal var density: Density by mutableStateOf(Density(1f, 1f))
+
+    /**
+     * Needed for [notifyPrefetch].
+     */
+    internal var isVertical: Boolean by mutableStateOf(true)
+
+    /**
+     * The ScrollableController instance. We keep it as we need to call stopAnimation on it once
+     * we reached the end of the grid.
+     */
+    private val scrollableState = ScrollableState { -onScroll(-it) }
+
+    /**
+     * Only used for testing to confirm that we're not making too many measure passes
+     */
+    /*@VisibleForTesting*/
+    internal var numMeasurePasses: Int = 0
+        private set
+
+    /**
+     * Only used for testing to disable prefetching when needed to test the main logic.
+     */
+    /*@VisibleForTesting*/
+    internal var prefetchingEnabled: Boolean = true
+
+    /**
+     * The index scheduled to be prefetched (or the last prefetched index if the prefetch is done).
+     */
+    private var lineToPrefetch = -1
+
+    /**
+     * The list of handles associated with the items from the [lineToPrefetch] line.
+     */
+    private var currentLinePrefetchHandles =
+        mutableVectorOf<LazyLayoutPrefetchState.PrefetchHandle>()
+
+    /**
+     * Keeps the scrolling direction during the previous calculation in order to be able to
+     * detect the scrolling direction change.
+     */
+    private var wasScrollingForward = false
+
+    /**
+     * The [Remeasurement] object associated with our layout. It allows us to remeasure
+     * synchronously during scroll.
+     */
+    private var remeasurement: Remeasurement? by mutableStateOf(null)
+
+    /**
+     * The modifier which provides [remeasurement].
+     */
+    internal val remeasurementModifier = object : RemeasurementModifier {
+        override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
+            this@TvLazyGridState.remeasurement = remeasurement
+        }
+    }
+
+    /**
+     * Provides a modifier which allows to delay some interactions (e.g. scroll)
+     * until layout is ready.
+     */
+    internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
+
+    /**
+     * Finds items on a line and their measurement constraints. Used for prefetching.
+     */
+    internal var prefetchInfoRetriever: (line: LineIndex) -> List<Pair<Int, Constraints>> by
+    mutableStateOf({ emptyList() })
+
+    internal var placementAnimator by mutableStateOf<LazyGridItemPlacementAnimator?>(null)
+
+    /**
+     * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
+     * pixels.
+     *
+     * @param index the index to which to scroll. Must be non-negative.
+     * @param scrollOffset the offset that the item should end up after the scroll. Note that
+     * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
+     * scroll the item further upward (taking it partly offscreen).
+     */
+    suspend fun scrollToItem(
+        /*@IntRange(from = 0)*/
+        index: Int,
+        scrollOffset: Int = 0
+    ) {
+        scroll {
+            snapToItemIndexInternal(index, scrollOffset)
+        }
+    }
+
+    internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
+        scrollPosition.requestPosition(ItemIndex(index), scrollOffset)
+        // placement animation is not needed because we snap into a new position.
+        placementAnimator?.reset()
+        remeasurement?.forceRemeasure()
+    }
+
+    /**
+     * Call this function to take control of scrolling and gain the ability to send scroll events
+     * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be
+     * performed within a [scroll] block (even if they don't call any other methods on this
+     * object) in order to guarantee that mutual exclusion is enforced.
+     *
+     * If [scroll] is called from elsewhere, this will be canceled.
+     */
+    override suspend fun scroll(
+        scrollPriority: MutatePriority,
+        block: suspend ScrollScope.() -> Unit
+    ) {
+        awaitLayoutModifier.waitForFirstLayout()
+        scrollableState.scroll(scrollPriority, block)
+    }
+
+    override fun dispatchRawDelta(delta: Float): Float =
+        scrollableState.dispatchRawDelta(delta)
+
+    override val isScrollInProgress: Boolean
+        get() = scrollableState.isScrollInProgress
+
+    private var canScrollBackward: Boolean = false
+    internal var canScrollForward: Boolean = false
+        private set
+
+    // TODO: Coroutine scrolling APIs will allow this to be private again once we have more
+    //  fine-grained control over scrolling
+    /*@VisibleForTesting*/
+    internal fun onScroll(distance: Float): Float {
+        if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) {
+            return 0f
+        }
+        check(abs(scrollToBeConsumed) <= 0.5f) {
+            "entered drag with non-zero pending scroll: $scrollToBeConsumed"
+        }
+        scrollToBeConsumed += distance
+
+        // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation
+        // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if
+        // we have less than 0.5 pixels
+        if (abs(scrollToBeConsumed) > 0.5f) {
+            val preScrollToBeConsumed = scrollToBeConsumed
+            remeasurement?.forceRemeasure()
+            if (prefetchingEnabled) {
+                notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumed)
+            }
+        }
+
+        // here scrollToBeConsumed is already consumed during the forceRemeasure invocation
+        if (abs(scrollToBeConsumed) <= 0.5f) {
+            // We consumed all of it - we'll hold onto the fractional scroll for later, so report
+            // that we consumed the whole thing
+            return distance
+        } else {
+            val scrollConsumed = distance - scrollToBeConsumed
+            // We did not consume all of it - return the rest to be consumed elsewhere (e.g.,
+            // nested scrolling)
+            scrollToBeConsumed = 0f // We're not consuming the rest, give it back
+            return scrollConsumed
+        }
+    }
+
+    private fun notifyPrefetch(delta: Float) {
+        val prefetchState = prefetchState
+        if (!prefetchingEnabled) {
+            return
+        }
+        val info = layoutInfo
+        if (info.visibleItemsInfo.isNotEmpty()) {
+            // check(isActive)
+            val scrollingForward = delta < 0
+            val lineToPrefetch: Int
+            val closestNextItemToPrefetch: Int
+            if (scrollingForward) {
+                lineToPrefetch = 1 + info.visibleItemsInfo.last().let {
+                    if (isVertical) it.row else it.column
+                }
+                closestNextItemToPrefetch = info.visibleItemsInfo.last().index + 1
+            } else {
+                lineToPrefetch = -1 + info.visibleItemsInfo.first().let {
+                    if (isVertical) it.row else it.column
+                }
+                closestNextItemToPrefetch = info.visibleItemsInfo.first().index - 1
+            }
+            if (lineToPrefetch != this.lineToPrefetch &&
+                closestNextItemToPrefetch in 0 until info.totalItemsCount
+            ) {
+                if (wasScrollingForward != scrollingForward) {
+                    // the scrolling direction has been changed which means the last prefetched
+                    // is not going to be reached anytime soon so it is safer to dispose it.
+                    // if this line is already visible it is safe to call the method anyway
+                    // as it will be no-op
+                    currentLinePrefetchHandles.forEach { it.cancel() }
+                }
+                this.wasScrollingForward = scrollingForward
+                this.lineToPrefetch = lineToPrefetch
+                currentLinePrefetchHandles.clear()
+                prefetchInfoRetriever(LineIndex(lineToPrefetch)).fastForEach {
+                    currentLinePrefetchHandles.add(
+                        prefetchState.schedulePrefetch(it.first, it.second)
+                    )
+                }
+            }
+        }
+    }
+
+    internal val prefetchState = LazyLayoutPrefetchState()
+
+    /**
+     * Animate (smooth scroll) to the given item.
+     *
+     * @param index the index to which to scroll. Must be non-negative.
+     * @param scrollOffset the offset that the item should end up after the scroll. Note that
+     * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
+     * scroll the item further upward (taking it partly offscreen).
+     */
+    suspend fun animateScrollToItem(
+        /*@IntRange(from = 0)*/
+        index: Int,
+        scrollOffset: Int = 0
+    ) {
+        doSmoothScrollToItem(index, scrollOffset, slotsPerLine)
+    }
+
+    /**
+     *  Updates the state with the new calculated scroll position and consumed scroll.
+     */
+    internal fun applyMeasureResult(result: TvLazyGridMeasureResult) {
+        scrollPosition.updateFromMeasureResult(result)
+        scrollToBeConsumed -= result.consumedScroll
+        layoutInfoState.value = result
+
+        canScrollForward = result.canScrollForward
+        canScrollBackward = (result.firstVisibleLine?.index?.value ?: 0) != 0 ||
+            result.firstVisibleLineScrollOffset != 0
+
+        numMeasurePasses++
+    }
+
+    /**
+     * When the user provided custom keys for the items we can try to detect when there were
+     * items added or removed before our current first visible item and keep this item
+     * as the first visible one even given that its index has been changed.
+     */
+    internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyGridItemProvider) {
+        scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+    }
+
+    companion object {
+        /**
+         * The default [Saver] implementation for [TvLazyGridState].
+         */
+        val Saver: Saver<TvLazyGridState, *> = listSaver(
+            save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
+            restore = {
+                TvLazyGridState(
+                    firstVisibleItemIndex = it[0],
+                    firstVisibleItemScrollOffset = it[1]
+                )
+            }
+        )
+    }
+}
+
+private object EmptyTvLazyGridLayoutInfo : TvLazyGridLayoutInfo {
+    override val visibleItemsInfo = emptyList<TvLazyGridItemInfo>()
+    override val viewportStartOffset = 0
+    override val viewportEndOffset = 0
+    override val totalItemsCount = 0
+    override val viewportSize = IntSize.Zero
+    override val orientation = Orientation.Vertical
+    override val reverseLayout = false
+    override val beforeContentPadding: Int = 0
+    override val afterContentPadding: Int = 0
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt
new file mode 100644
index 0000000..7480db2
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+/**
+ * Represents an index in the list of items of lazy layout.
+ */
+@Suppress("NOTHING_TO_INLINE")
+@kotlin.jvm.JvmInline
+internal value class DataIndex(val value: Int) {
+    inline operator fun inc(): DataIndex = DataIndex(value + 1)
+    inline operator fun dec(): DataIndex = DataIndex(value - 1)
+    inline operator fun plus(i: Int): DataIndex = DataIndex(value + i)
+    inline operator fun minus(i: Int): DataIndex = DataIndex(value - i)
+    inline operator fun minus(i: DataIndex): DataIndex = DataIndex(value - i.value)
+    inline operator fun compareTo(other: DataIndex): Int = value - other.value
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyDsl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyDsl.kt
new file mode 100644
index 0000000..64c697a
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyDsl.kt
@@ -0,0 +1,298 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+
+/* Copied from
+ compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt
+  and modified */
+
+/**
+ * Receiver scope which is used by [TvLazyColumn] and [TvLazyRow].
+ */
+@TvLazyListScopeMarker
+sealed interface TvLazyListScope {
+    /**
+     * Adds a single item.
+     *
+     * @param key a stable and unique key representing the item. Using the same key
+     * for multiple items in the list is not allowed. Type of the key should be saveable
+     * via Bundle on Android. If null is passed the position in the list will represent the key.
+     * When you specify the key the scroll position will be maintained based on the key, which
+     * means if you add/remove items before the current visible item the item with the given key
+     * will be kept as the first visible one.
+     * @param contentType the type of the content of this item. The item compositions of the same
+     * type could be reused more efficiently. Note that null is a valid type and items of such
+     * type will be considered compatible.
+     * @param content the content of the item
+     */
+    fun item(
+        key: Any? = null,
+        contentType: Any? = null,
+        content: @Composable TvLazyListItemScope.() -> Unit
+    )
+
+    /**
+     * Adds a [count] of items.
+     *
+     * @param count the items count
+     * @param key a factory of stable and unique keys representing the item. Using the same key
+     * for multiple items in the list is not allowed. Type of the key should be saveable
+     * via Bundle on Android. If null is passed the position in the list will represent the key.
+     * When you specify the key the scroll position will be maintained based on the key, which
+     * means if you add/remove items before the current visible item the item with the given key
+     * will be kept as the first visible one.
+     * @param contentType a factory of the content types for the item. The item compositions of
+     * the same type could be reused more efficiently. Note that null is a valid type and items of such
+     * type will be considered compatible.
+     * @param itemContent the content displayed by a single item
+     */
+    fun items(
+        count: Int,
+        key: ((index: Int) -> Any)? = null,
+        contentType: (index: Int) -> Any? = { null },
+        itemContent: @Composable TvLazyListItemScope.(index: Int) -> Unit
+    )
+}
+
+/**
+ * Adds a list of items.
+ *
+ * @param items the data list
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the list is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the list will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyListScope.items(
+    items: List<T>,
+    noinline key: ((item: T) -> Any)? = null,
+    noinline contentType: (item: T) -> Any? = { null },
+    crossinline itemContent: @Composable TvLazyListItemScope.(item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(items[index]) } else null,
+    contentType = { index: Int -> contentType(items[index]) }
+) {
+    itemContent(items[it])
+}
+
+/**
+ * Adds a list of items where the content of an item is aware of its index.
+ *
+ * @param items the data list
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the list is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the list will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyListScope.itemsIndexed(
+    items: List<T>,
+    noinline key: ((index: Int, item: T) -> Any)? = null,
+    crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
+    crossinline itemContent: @Composable TvLazyListItemScope.(index: Int, item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(index, items[index]) } else null,
+    contentType = { index -> contentType(index, items[index]) }
+) {
+    itemContent(it, items[it])
+}
+
+/**
+ * Adds an array of items.
+ *
+ * @param items the data array
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the list is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the list will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyListScope.items(
+    items: Array<T>,
+    noinline key: ((item: T) -> Any)? = null,
+    noinline contentType: (item: T) -> Any? = { null },
+    crossinline itemContent: @Composable TvLazyListItemScope.(item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(items[index]) } else null,
+    contentType = { index: Int -> contentType(items[index]) }
+) {
+    itemContent(items[it])
+}
+
+/**
+ * Adds an array of items where the content of an item is aware of its index.
+ *
+ * @param items the data array
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the list is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the list will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
+ * @param contentType a factory of the content types for the item. The item compositions of
+ * the same type could be reused more efficiently. Note that null is a valid type and items of such
+ * type will be considered compatible.
+ * @param itemContent the content displayed by a single item
+ */
+inline fun <T> TvLazyListScope.itemsIndexed(
+    items: Array<T>,
+    noinline key: ((index: Int, item: T) -> Any)? = null,
+    crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
+    crossinline itemContent: @Composable TvLazyListItemScope.(index: Int, item: T) -> Unit
+) = items(
+    count = items.size,
+    key = if (key != null) { index: Int -> key(index, items[index]) } else null,
+    contentType = { index -> contentType(index, items[index]) }
+) {
+    itemContent(it, items[it])
+}
+
+/**
+ * The horizontally scrolling list that only composes and lays out the currently visible items.
+ * The [content] block defines a DSL which allows you to emit items of different types. For
+ * example you can use [TvLazyListScope.item] to add a single item and [TvLazyListScope.items] to add
+ * a list of items.
+ *
+ * @param modifier the modifier to apply to this layout
+ * @param state the state object to be used to control or observe the list's state
+ * @param contentPadding a padding around the whole content. This will add padding for the
+ * content after it has been clipped, which is not possible via [modifier] param. You can use it
+ * to add a padding before the first item or after the last one. If you want to add a spacing
+ * between each item use [horizontalArrangement].
+ * @param reverseLayout reverse the direction of scrolling and layout. When `true`, items are
+ * laid out in the reverse order and [TvLazyListState.firstVisibleItemIndex] == 0 means
+ * that row is scrolled to the end. Note that [reverseLayout] does not change the behavior of
+ * [horizontalArrangement], e.g. with [Arrangement.Start] [123###] becomes [321###].
+ * @param horizontalArrangement The horizontal arrangement of the layout's children. This allows
+ * to add a spacing between items and specify the arrangement of the items when we have not enough
+ * of them to fill the whole minimum size.
+ * @param verticalAlignment the vertical alignment applied to the items
+ * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions
+ * is allowed. You can still scroll programmatically using the state even when it is disabled.
+ * @param pivotOffsets offsets of child element within the parent and starting edge of the child
+ * from the pivot defined by the parentOffset.
+ * @param content a block which describes the content. Inside this block you can use methods like
+ * [TvLazyListScope.item] to add a single item or [TvLazyListScope.items] to add a list of items.
+ */
+@Composable
+fun TvLazyRow(
+    modifier: Modifier = Modifier,
+    state: TvLazyListState = rememberTvLazyListState(),
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    reverseLayout: Boolean = false,
+    horizontalArrangement: Arrangement.Horizontal =
+        if (!reverseLayout) Arrangement.Start else Arrangement.End,
+    verticalAlignment: Alignment.Vertical = Alignment.Top,
+    userScrollEnabled: Boolean = true,
+    pivotOffsets: PivotOffsets = PivotOffsets(),
+    content: TvLazyListScope.() -> Unit
+) {
+    LazyList(
+        modifier = modifier,
+        state = state,
+        contentPadding = contentPadding,
+        verticalAlignment = verticalAlignment,
+        horizontalArrangement = horizontalArrangement,
+        isVertical = false,
+        reverseLayout = reverseLayout,
+        userScrollEnabled = userScrollEnabled,
+        content = content,
+        pivotOffsets = pivotOffsets
+    )
+}
+
+/**
+ * The vertically scrolling list that only composes and lays out the currently visible items.
+ * The [content] block defines a DSL which allows you to emit items of different types. For
+ * example you can use [TvLazyListScope.item] to add a single item and [TvLazyListScope.items] to add
+ * a list of items.
+ *
+ * @param modifier the modifier to apply to this layout.
+ * @param state the state object to be used to control or observe the list's state.
+ * @param contentPadding a padding around the whole content. This will add padding for the.
+ * content after it has been clipped, which is not possible via [modifier] param. You can use it
+ * to add a padding before the first item or after the last one. If you want to add a spacing
+ * between each item use [verticalArrangement].
+ * @param reverseLayout reverse the direction of scrolling and layout. When `true`, items are
+ * laid out in the reverse order and [TvLazyListState.firstVisibleItemIndex] == 0 means
+ * that column is scrolled to the bottom. Note that [reverseLayout] does not change the behavior of
+ * [verticalArrangement],
+ * e.g. with [Arrangement.Top] (top) 123### (bottom) becomes (top) 321### (bottom).
+ * @param verticalArrangement The vertical arrangement of the layout's children. This allows
+ * to add a spacing between items and specify the arrangement of the items when we have not enough
+ * of them to fill the whole minimum size.
+ * @param horizontalAlignment the horizontal alignment applied to the items.
+ * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions
+ * is allowed. You can still scroll programmatically using the state even when it is disabled.
+ * @param content a block which describes the content. Inside this block you can use methods like
+ * @param pivotOffsets offsets of child element within the parent and starting edge of the child
+ * from the pivot defined by the parentOffset.
+ * [TvLazyListScope.item] to add a single item or [TvLazyListScope.items] to add a list of items.
+ */
+@Composable
+fun TvLazyColumn(
+    modifier: Modifier = Modifier,
+    state: TvLazyListState = rememberTvLazyListState(),
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    reverseLayout: Boolean = false,
+    verticalArrangement: Arrangement.Vertical =
+        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+    userScrollEnabled: Boolean = true,
+    pivotOffsets: PivotOffsets = PivotOffsets(),
+    content: TvLazyListScope.() -> Unit
+) {
+    LazyList(
+        modifier = modifier,
+        state = state,
+        contentPadding = contentPadding,
+        horizontalAlignment = horizontalAlignment,
+        verticalArrangement = verticalArrangement,
+        isVertical = true,
+        reverseLayout = reverseLayout,
+        userScrollEnabled = userScrollEnabled,
+        content = content,
+        pivotOffsets = pivotOffsets
+    )
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
new file mode 100644
index 0000000..6ee45b7
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.checkScrollableContainerConstraints
+import androidx.compose.foundation.clipScrollableContainer
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.lazy.layout.LazyLayout
+import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.offset
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.LazyListBeyondBoundsInfo
+import androidx.tv.foundation.lazy.lazyListBeyondBoundsModifier
+import androidx.tv.foundation.lazy.lazyListPinningModifier
+import androidx.tv.foundation.marioScrollable
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+internal fun LazyList(
+    /** Modifier to be applied for the inner layout */
+    modifier: Modifier,
+    /** State controlling the scroll position */
+    state: TvLazyListState,
+    /** The inner padding to be added for the whole content(not for each individual item) */
+    contentPadding: PaddingValues,
+    /** reverse the direction of scrolling and layout */
+    reverseLayout: Boolean,
+    /** The layout orientation of the list */
+    isVertical: Boolean,
+    /** Whether scrolling via the user gestures is allowed. */
+    userScrollEnabled: Boolean,
+    /** offsets of child element within the parent and starting edge of the child from the pivot
+     * defined by the parentOffset. */
+    pivotOffsets: PivotOffsets,
+    /** The alignment to align items horizontally. Required when isVertical is true */
+    horizontalAlignment: Alignment.Horizontal? = null,
+    /** The vertical arrangement for items. Required when isVertical is true */
+    verticalArrangement: Arrangement.Vertical? = null,
+    /** The alignment to align items vertically. Required when isVertical is false */
+    verticalAlignment: Alignment.Vertical? = null,
+    /** The horizontal arrangement for items. Required when isVertical is false */
+    horizontalArrangement: Arrangement.Horizontal? = null,
+    /** The content of the list */
+    content: TvLazyListScope.() -> Unit
+) {
+    val itemProvider = rememberItemProvider(state, content)
+    val beyondBoundsInfo = remember { LazyListBeyondBoundsInfo() }
+    val scope = rememberCoroutineScope()
+    val placementAnimator = remember(state, isVertical) {
+        LazyListItemPlacementAnimator(scope, isVertical)
+    }
+    state.placementAnimator = placementAnimator
+
+    val measurePolicy = rememberLazyListMeasurePolicy(
+        itemProvider,
+        state,
+        beyondBoundsInfo,
+        contentPadding,
+        reverseLayout,
+        isVertical,
+        horizontalAlignment,
+        verticalAlignment,
+        horizontalArrangement,
+        verticalArrangement,
+        placementAnimator
+    )
+
+    ScrollPositionUpdater(itemProvider, state)
+
+    val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
+
+    LazyLayout(
+        modifier = modifier
+            .then(state.remeasurementModifier)
+            .then(state.awaitLayoutModifier)
+            .lazyListSemantics(
+                itemProvider = itemProvider,
+                state = state,
+                coroutineScope = scope,
+                isVertical = isVertical,
+                reverseScrolling = reverseLayout,
+                userScrollEnabled = userScrollEnabled
+            )
+            .clipScrollableContainer(orientation)
+            .lazyListBeyondBoundsModifier(state, beyondBoundsInfo, reverseLayout)
+            .lazyListPinningModifier(state, beyondBoundsInfo)
+            .marioScrollable(
+                orientation = orientation,
+                reverseDirection = run {
+                    // A finger moves with the content, not with the viewport. Therefore,
+                    // always reverse once to have "natural" gesture that goes reversed to layout
+                    var reverseDirection = !reverseLayout
+                    // But if rtl and horizontal, things move the other way around
+                    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+                    if (isRtl && !isVertical) {
+                        reverseDirection = !reverseDirection
+                    }
+                    reverseDirection
+                },
+                state = state,
+                enabled = userScrollEnabled,
+                pivotOffsets = pivotOffsets
+            ),
+        prefetchState = state.prefetchState,
+        measurePolicy = measurePolicy,
+        itemProvider = itemProvider
+    )
+}
+
+/** Extracted to minimize the recomposition scope */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+@Composable
+private fun ScrollPositionUpdater(
+    itemProvider: LazyListItemProvider,
+    state: TvLazyListState
+) {
+    if (itemProvider.itemCount > 0) {
+        state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+    }
+}
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+@Composable
+private fun rememberLazyListMeasurePolicy(
+    /** Items provider of the list. */
+    itemProvider: LazyListItemProvider,
+    /** The state of the list. */
+    state: TvLazyListState,
+    /** Keeps track of the number of items we measure and place that are beyond visible bounds. */
+    beyondBoundsInfo: LazyListBeyondBoundsInfo,
+    /** The inner padding to be added for the whole content(nor for each individual item) */
+    contentPadding: PaddingValues,
+    /** reverse the direction of scrolling and layout */
+    reverseLayout: Boolean,
+    /** The layout orientation of the list */
+    isVertical: Boolean,
+    /** The alignment to align items horizontally. Required when isVertical is true */
+    horizontalAlignment: Alignment.Horizontal? = null,
+    /** The alignment to align items vertically. Required when isVertical is false */
+    verticalAlignment: Alignment.Vertical? = null,
+    /** The horizontal arrangement for items. Required when isVertical is false */
+    horizontalArrangement: Arrangement.Horizontal? = null,
+    /** The vertical arrangement for items. Required when isVertical is true */
+    verticalArrangement: Arrangement.Vertical? = null,
+    /** Item placement animator. Should be notified with the measuring result */
+    placementAnimator: LazyListItemPlacementAnimator
+) = remember<LazyLayoutMeasureScope.(Constraints) -> MeasureResult>(
+    state,
+    beyondBoundsInfo,
+    contentPadding,
+    reverseLayout,
+    isVertical,
+    horizontalAlignment,
+    verticalAlignment,
+    horizontalArrangement,
+    verticalArrangement,
+    placementAnimator
+) {
+    { containerConstraints ->
+        checkScrollableContainerConstraints(
+            containerConstraints,
+            if (isVertical) Orientation.Vertical else Orientation.Horizontal
+        )
+
+        // resolve content paddings
+        val startPadding =
+            if (isVertical) {
+                contentPadding.calculateLeftPadding(layoutDirection).roundToPx()
+            } else {
+                // in horizontal configuration, padding is reversed by placeRelative
+                contentPadding.calculateStartPadding(layoutDirection).roundToPx()
+            }
+
+        val endPadding =
+            if (isVertical) {
+                contentPadding.calculateRightPadding(layoutDirection).roundToPx()
+            } else {
+                // in horizontal configuration, padding is reversed by placeRelative
+                contentPadding.calculateEndPadding(layoutDirection).roundToPx()
+            }
+        val topPadding = contentPadding.calculateTopPadding().roundToPx()
+        val bottomPadding = contentPadding.calculateBottomPadding().roundToPx()
+        val totalVerticalPadding = topPadding + bottomPadding
+        val totalHorizontalPadding = startPadding + endPadding
+        val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding
+        val beforeContentPadding = when {
+            isVertical && !reverseLayout -> topPadding
+            isVertical && reverseLayout -> bottomPadding
+            !isVertical && !reverseLayout -> startPadding
+            else -> endPadding // !isVertical && reverseLayout
+        }
+        val afterContentPadding = totalMainAxisPadding - beforeContentPadding
+        val contentConstraints =
+            containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding)
+
+        state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+
+        // Update the state's cached Density
+        state.density = this
+
+        // this will update the scope used by the item composables
+        itemProvider.itemScope.maxWidth = contentConstraints.maxWidth.toDp()
+        itemProvider.itemScope.maxHeight = contentConstraints.maxHeight.toDp()
+
+        val spaceBetweenItemsDp = if (isVertical) {
+            requireNotNull(verticalArrangement).spacing
+        } else {
+            requireNotNull(horizontalArrangement).spacing
+        }
+        val spaceBetweenItems = spaceBetweenItemsDp.roundToPx()
+
+        val itemsCount = itemProvider.itemCount
+
+        // can be negative if the content padding is larger than the max size from constraints
+        val mainAxisAvailableSize = if (isVertical) {
+            containerConstraints.maxHeight - totalVerticalPadding
+        } else {
+            containerConstraints.maxWidth - totalHorizontalPadding
+        }
+        val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) {
+            IntOffset(startPadding, topPadding)
+        } else {
+            // When layout is reversed and paddings together take >100% of the available space,
+            // layout size is coerced to 0 when positioning. To take that space into account,
+            // we offset start padding by negative space between paddings.
+            IntOffset(
+                if (isVertical) startPadding else startPadding + mainAxisAvailableSize,
+                if (isVertical) topPadding + mainAxisAvailableSize else topPadding
+            )
+        }
+
+        val measuredItemProvider = LazyMeasuredItemProvider(
+            contentConstraints,
+            isVertical,
+            itemProvider,
+            this
+        ) { index, key, placeables ->
+            // we add spaceBetweenItems as an extra spacing for all items apart from the last one so
+            // the lazy list measuring logic will take it into account.
+            val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems
+            LazyMeasuredItem(
+                index = index.value,
+                placeables = placeables,
+                isVertical = isVertical,
+                horizontalAlignment = horizontalAlignment,
+                verticalAlignment = verticalAlignment,
+                layoutDirection = layoutDirection,
+                reverseLayout = reverseLayout,
+                beforeContentPadding = beforeContentPadding,
+                afterContentPadding = afterContentPadding,
+                spacing = spacing,
+                visualOffset = visualItemOffset,
+                key = key,
+                placementAnimator = placementAnimator
+            )
+        }
+        state.premeasureConstraints = measuredItemProvider.childConstraints
+
+        val firstVisibleItemIndex: DataIndex
+        val firstVisibleScrollOffset: Int
+        Snapshot.withoutReadObservation {
+            firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex)
+            firstVisibleScrollOffset = state.firstVisibleItemScrollOffset
+        }
+
+        measureLazyList(
+            itemsCount = itemsCount,
+            itemProvider = measuredItemProvider,
+            mainAxisAvailableSize = mainAxisAvailableSize,
+            beforeContentPadding = beforeContentPadding,
+            afterContentPadding = afterContentPadding,
+            firstVisibleItemIndex = firstVisibleItemIndex,
+            firstVisibleItemScrollOffset = firstVisibleScrollOffset,
+            scrollToBeConsumed = state.scrollToBeConsumed,
+            constraints = contentConstraints,
+            isVertical = isVertical,
+            headerIndexes = itemProvider.headerIndexes,
+            verticalArrangement = verticalArrangement,
+            horizontalArrangement = horizontalArrangement,
+            reverseLayout = reverseLayout,
+            density = this,
+            placementAnimator = placementAnimator,
+            beyondBoundsInfo = beyondBoundsInfo,
+            layout = { width, height, placement ->
+                layout(
+                    containerConstraints.constrainWidth(width + totalHorizontalPadding),
+                    containerConstraints.constrainHeight(height + totalVerticalPadding),
+                    emptyMap(),
+                    placement
+                )
+            }
+        ).also { state.applyMeasureResult(it) }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt
new file mode 100644
index 0000000..d20d6f5
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.ui.util.fastForEachIndexed
+
+/**
+ * This method finds the sticky header in composedItems list or composes the header item if needed.
+ *
+ * @param composedVisibleItems list of items already composed and expected to be visible. if the
+ * header wasn't in this list but is needed the header will be added as the first item in this list.
+ * @param itemProvider the provider so we can compose a header if it wasn't composed already
+ * @param headerIndexes list of indexes of headers. Must be sorted.
+ * @param beforeContentPadding the padding before the first item in the list
+ */
+internal fun findOrComposeLazyListHeader(
+    composedVisibleItems: MutableList<LazyListPositionedItem>,
+    itemProvider: LazyMeasuredItemProvider,
+    headerIndexes: List<Int>,
+    beforeContentPadding: Int,
+    layoutWidth: Int,
+    layoutHeight: Int,
+): LazyListPositionedItem? {
+    var currentHeaderOffset: Int = Int.MIN_VALUE
+    var nextHeaderOffset: Int = Int.MIN_VALUE
+
+    var currentHeaderListPosition = -1
+    var nextHeaderListPosition = -1
+    // we use visibleItemsInfo and not firstVisibleItemIndex as visibleItemsInfo list also
+    // contains all the items which are visible in the start content padding area
+    val firstVisible = composedVisibleItems.first().index
+    // find the header which can be displayed
+    for (index in headerIndexes.indices) {
+        if (headerIndexes[index] <= firstVisible) {
+            currentHeaderListPosition = headerIndexes[index]
+            nextHeaderListPosition = headerIndexes.getOrElse(index + 1) { -1 }
+        } else {
+            break
+        }
+    }
+
+    var indexInComposedVisibleItems = -1
+    composedVisibleItems.fastForEachIndexed { index, item ->
+        if (item.index == currentHeaderListPosition) {
+            indexInComposedVisibleItems = index
+            currentHeaderOffset = item.offset
+        } else {
+            if (item.index == nextHeaderListPosition) {
+                nextHeaderOffset = item.offset
+            }
+        }
+    }
+
+    if (currentHeaderListPosition == -1) {
+        // we have no headers needing special handling
+        return null
+    }
+
+    val measuredHeaderItem = itemProvider.getAndMeasure(DataIndex(currentHeaderListPosition))
+
+    var headerOffset = if (currentHeaderOffset != Int.MIN_VALUE) {
+        maxOf(-beforeContentPadding, currentHeaderOffset)
+    } else {
+        -beforeContentPadding
+    }
+    // if we have a next header overlapping with the current header, the next one will be
+    // pushing the current one away from the viewport.
+    if (nextHeaderOffset != Int.MIN_VALUE) {
+        headerOffset = minOf(headerOffset, nextHeaderOffset - measuredHeaderItem.size)
+    }
+
+    return measuredHeaderItem.position(headerOffset, layoutWidth, layoutHeight).also {
+        if (indexInComposedVisibleItems != -1) {
+            composedVisibleItems[indexInComposedVisibleItems] = it
+        } else {
+            composedVisibleItems.add(0, it)
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt
new file mode 100644
index 0000000..317b454
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt
@@ -0,0 +1,441 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Handles the item placement animations when it is set via [LazyItemScope.animateItemPlacement].
+ *
+ * This class is responsible for detecting when item position changed, figuring our start/end
+ * offsets and starting the animations.
+ */
+internal class LazyListItemPlacementAnimator(
+    private val scope: CoroutineScope,
+    private val isVertical: Boolean
+) {
+    // state containing an animation and all relevant info for each item.
+    private val keyToItemInfoMap = mutableMapOf<Any, ItemInfo>()
+
+    // snapshot of the key to index map used for the last measuring.
+    private var keyToIndexMap: Map<Any, Int> = emptyMap()
+
+    // keeps the first and the last items positioned in the viewport and their visible part sizes.
+    private var viewportStartItemIndex = -1
+    private var viewportStartItemNotVisiblePartSize = 0
+    private var viewportEndItemIndex = -1
+    private var viewportEndItemNotVisiblePartSize = 0
+
+    // stored to not allocate it every pass.
+    private val positionedKeys = mutableSetOf<Any>()
+
+    /**
+     * Should be called after the measuring so we can detect position changes and start animations.
+     *
+     * Note that this method can compose new item and add it into the [positionedItems] list.
+     */
+    fun onMeasured(
+        consumedScroll: Int,
+        layoutWidth: Int,
+        layoutHeight: Int,
+        reverseLayout: Boolean,
+        positionedItems: MutableList<LazyListPositionedItem>,
+        itemProvider: LazyMeasuredItemProvider,
+    ) {
+        if (!positionedItems.fastAny { it.hasAnimations }) {
+            // no animations specified - no work needed
+            reset()
+            return
+        }
+
+        val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+
+        // the consumed scroll is considered as a delta we don't need to animate
+        val notAnimatableDelta = (if (reverseLayout) -consumedScroll else consumedScroll).toOffset()
+
+        val newFirstItem = positionedItems.first()
+        val newLastItem = positionedItems.last()
+
+        var totalItemsSize = 0
+        // update known indexes and calculate the average size
+        positionedItems.fastForEach { item ->
+            keyToItemInfoMap[item.key]?.index = item.index
+            totalItemsSize += item.sizeWithSpacings
+        }
+        val averageItemSize = totalItemsSize / positionedItems.size
+
+        positionedKeys.clear()
+        // iterate through the items which are visible (without animated offsets)
+        positionedItems.fastForEach { item ->
+            positionedKeys.add(item.key)
+            val itemInfo = keyToItemInfoMap[item.key]
+            if (itemInfo == null) {
+                // there is no state associated with this item yet
+                if (item.hasAnimations) {
+                    val newItemInfo = ItemInfo(item.index)
+                    val previousIndex = keyToIndexMap[item.key]
+                    val firstPlaceableOffset = item.getOffset(0)
+                    val firstPlaceableSize = item.getMainAxisSize(0)
+
+                    val targetFirstPlaceableOffsetMainAxis = if (previousIndex == null) {
+                        // it is a completely new item. no animation is needed
+                        firstPlaceableOffset.mainAxis
+                    } else {
+                        val fallback = if (!reverseLayout) {
+                            firstPlaceableOffset.mainAxis
+                        } else {
+                            firstPlaceableOffset.mainAxis - item.sizeWithSpacings +
+                                firstPlaceableSize
+                        }
+                        calculateExpectedOffset(
+                            index = previousIndex,
+                            sizeWithSpacings = item.sizeWithSpacings,
+                            averageItemsSize = averageItemSize,
+                            scrolledBy = notAnimatableDelta,
+                            fallback = fallback,
+                            reverseLayout = reverseLayout,
+                            mainAxisLayoutSize = mainAxisLayoutSize,
+                            visibleItems = positionedItems
+                        ) + if (reverseLayout) {
+                            item.size - firstPlaceableSize
+                        } else {
+                            0
+                        }
+                    }
+                    val targetFirstPlaceableOffset = if (isVertical) {
+                        firstPlaceableOffset.copy(y = targetFirstPlaceableOffsetMainAxis)
+                    } else {
+                        firstPlaceableOffset.copy(x = targetFirstPlaceableOffsetMainAxis)
+                    }
+
+                    // populate placeable info list
+                    repeat(item.placeablesCount) { placeableIndex ->
+                        val diffToFirstPlaceableOffset =
+                            item.getOffset(placeableIndex) - firstPlaceableOffset
+                        newItemInfo.placeables.add(
+                            PlaceableInfo(
+                                targetFirstPlaceableOffset + diffToFirstPlaceableOffset,
+                                item.getMainAxisSize(placeableIndex)
+                            )
+                        )
+                    }
+                    keyToItemInfoMap[item.key] = newItemInfo
+                    startAnimationsIfNeeded(item, newItemInfo)
+                }
+            } else {
+                if (item.hasAnimations) {
+                    // apply new not animatable offset
+                    itemInfo.notAnimatableDelta += notAnimatableDelta
+                    startAnimationsIfNeeded(item, itemInfo)
+                } else {
+                    // no animation, clean up if needed
+                    keyToItemInfoMap.remove(item.key)
+                }
+            }
+        }
+
+        // previously we were animating items which are visible in the end state so we had to
+        // compare the current state with the state used for the previous measuring.
+        // now we will animate disappearing items so the current state is their starting state
+        // so we can update current viewport start/end items
+        if (!reverseLayout) {
+            viewportStartItemIndex = newFirstItem.index
+            viewportStartItemNotVisiblePartSize = newFirstItem.offset
+            viewportEndItemIndex = newLastItem.index
+            viewportEndItemNotVisiblePartSize =
+                newLastItem.offset + newLastItem.sizeWithSpacings - mainAxisLayoutSize
+        } else {
+            viewportStartItemIndex = newLastItem.index
+            viewportStartItemNotVisiblePartSize =
+                mainAxisLayoutSize - newLastItem.offset - newLastItem.size
+            viewportEndItemIndex = newFirstItem.index
+            viewportEndItemNotVisiblePartSize =
+                -newFirstItem.offset + (newFirstItem.sizeWithSpacings - newFirstItem.size)
+        }
+
+        val iterator = keyToItemInfoMap.iterator()
+        while (iterator.hasNext()) {
+            val entry = iterator.next()
+            if (!positionedKeys.contains(entry.key)) {
+                // found an item which was in our map previously but is not a part of the
+                // positionedItems now
+                val itemInfo = entry.value
+                // apply new not animatable delta for this item
+                itemInfo.notAnimatableDelta += notAnimatableDelta
+
+                val index = itemProvider.keyToIndexMap[entry.key]
+
+                // whether at least one placeable is within the viewport bounds.
+                // this usually means that we will start animation for it right now
+                val withinBounds = itemInfo.placeables.fastAny {
+                    val currentTarget = it.targetOffset + itemInfo.notAnimatableDelta
+                    currentTarget.mainAxis + it.size > 0 &&
+                        currentTarget.mainAxis < mainAxisLayoutSize
+                }
+
+                // whether the animation associated with the item has been finished
+                val isFinished = !itemInfo.placeables.fastAny { it.inProgress }
+
+                if ((!withinBounds && isFinished) ||
+                    index == null ||
+                    itemInfo.placeables.isEmpty()
+                ) {
+                    iterator.remove()
+                } else {
+
+                    val measuredItem = itemProvider.getAndMeasure(DataIndex(index))
+
+                    // calculate the target offset for the animation.
+                    val absoluteTargetOffset = calculateExpectedOffset(
+                        index = index,
+                        sizeWithSpacings = measuredItem.sizeWithSpacings,
+                        averageItemsSize = averageItemSize,
+                        scrolledBy = notAnimatableDelta,
+                        fallback = mainAxisLayoutSize,
+                        reverseLayout = reverseLayout,
+                        mainAxisLayoutSize = mainAxisLayoutSize,
+                        visibleItems = positionedItems
+                    )
+                    val targetOffset = if (reverseLayout) {
+                        mainAxisLayoutSize - absoluteTargetOffset - measuredItem.size
+                    } else {
+                        absoluteTargetOffset
+                    }
+
+                    val item = measuredItem.position(targetOffset, layoutWidth, layoutHeight)
+                    positionedItems.add(item)
+                    startAnimationsIfNeeded(item, itemInfo)
+                }
+            }
+        }
+
+        keyToIndexMap = itemProvider.keyToIndexMap
+    }
+
+    /**
+     * Returns the current animated item placement offset. By calling it only during the layout
+     * phase we can skip doing remeasure on every animation frame.
+     */
+    fun getAnimatedOffset(
+        key: Any,
+        placeableIndex: Int,
+        minOffset: Int,
+        maxOffset: Int,
+        rawOffset: IntOffset
+    ): IntOffset {
+        val itemInfo = keyToItemInfoMap[key] ?: return rawOffset
+        val item = itemInfo.placeables[placeableIndex]
+        val currentValue = item.animatedOffset.value + itemInfo.notAnimatableDelta
+        val currentTarget = item.targetOffset + itemInfo.notAnimatableDelta
+
+        // cancel the animation if it is fully out of the bounds.
+        if (item.inProgress &&
+            ((currentTarget.mainAxis < minOffset && currentValue.mainAxis < minOffset) ||
+            (currentTarget.mainAxis > maxOffset && currentValue.mainAxis > maxOffset))
+        ) {
+            scope.launch {
+                item.animatedOffset.snapTo(item.targetOffset)
+                item.inProgress = false
+            }
+        }
+
+        return currentValue
+    }
+
+    /**
+     * Should be called when the animations are not needed for the next positions change,
+     * for example when we snap to a new position.
+     */
+    fun reset() {
+        keyToItemInfoMap.clear()
+        keyToIndexMap = emptyMap()
+        viewportStartItemIndex = -1
+        viewportStartItemNotVisiblePartSize = 0
+        viewportEndItemIndex = -1
+        viewportEndItemNotVisiblePartSize = 0
+    }
+
+    /**
+     * Estimates the outside of the viewport offset for the item. Used to understand from
+     * where to start animation for the item which wasn't visible previously or where it should
+     * end for the item which is not going to be visible in the end.
+     */
+    private fun calculateExpectedOffset(
+        index: Int,
+        sizeWithSpacings: Int,
+        averageItemsSize: Int,
+        scrolledBy: IntOffset,
+        reverseLayout: Boolean,
+        mainAxisLayoutSize: Int,
+        fallback: Int,
+        visibleItems: List<LazyListPositionedItem>
+    ): Int {
+        val afterViewportEnd =
+            if (!reverseLayout) viewportEndItemIndex < index else viewportEndItemIndex > index
+        val beforeViewportStart =
+            if (!reverseLayout) viewportStartItemIndex > index else viewportStartItemIndex < index
+        return when {
+            afterViewportEnd -> {
+                var itemsSizes = 0
+                // add sizes of the items between the last visible one and this one.
+                val range = if (!reverseLayout) {
+                    viewportEndItemIndex + 1 until index
+                } else {
+                    index + 1 until viewportEndItemIndex
+                }
+                for (i in range) {
+                    itemsSizes += visibleItems.getItemSize(
+                        itemIndex = i,
+                        fallback = averageItemsSize
+                    )
+                }
+                mainAxisLayoutSize + viewportEndItemNotVisiblePartSize + itemsSizes +
+                    scrolledBy.mainAxis
+            }
+            beforeViewportStart -> {
+                // add the size of this item as we need the start offset of this item.
+                var itemsSizes = sizeWithSpacings
+                // add sizes of the items between the first visible one and this one.
+                val range = if (!reverseLayout) {
+                    index + 1 until viewportStartItemIndex
+                } else {
+                    viewportStartItemIndex + 1 until index
+                }
+                for (i in range) {
+                    itemsSizes += visibleItems.getItemSize(
+                        itemIndex = i,
+                        fallback = averageItemsSize
+                    )
+                }
+                viewportStartItemNotVisiblePartSize - itemsSizes + scrolledBy.mainAxis
+            }
+            else -> {
+                fallback
+            }
+        }
+    }
+
+    private fun List<LazyListPositionedItem>.getItemSize(itemIndex: Int, fallback: Int): Int {
+        if (isEmpty() || itemIndex < first().index || itemIndex > last().index) return fallback
+        if ((itemIndex - first().index) < (last().index - itemIndex)) {
+            for (index in indices) {
+                val item = get(index)
+                if (item.index == itemIndex) return item.sizeWithSpacings
+                if (item.index > itemIndex) break
+            }
+        } else {
+            for (index in lastIndex downTo 0) {
+                val item = get(index)
+                if (item.index == itemIndex) return item.sizeWithSpacings
+                if (item.index < itemIndex) break
+            }
+        }
+        return fallback
+    }
+
+    private fun startAnimationsIfNeeded(item: LazyListPositionedItem, itemInfo: ItemInfo) {
+        // first we make sure our item info is up to date (has the item placeables count)
+        while (itemInfo.placeables.size > item.placeablesCount) {
+            itemInfo.placeables.removeLast()
+        }
+        while (itemInfo.placeables.size < item.placeablesCount) {
+            val newPlaceableInfoIndex = itemInfo.placeables.size
+            val rawOffset = item.getOffset(newPlaceableInfoIndex)
+            itemInfo.placeables.add(
+                PlaceableInfo(
+                    rawOffset - itemInfo.notAnimatableDelta,
+                    item.getMainAxisSize(newPlaceableInfoIndex)
+                )
+            )
+        }
+
+        itemInfo.placeables.fastForEachIndexed { index, placeableInfo ->
+            val currentTarget = placeableInfo.targetOffset + itemInfo.notAnimatableDelta
+            val currentOffset = item.getOffset(index)
+            placeableInfo.size = item.getMainAxisSize(index)
+            val animationSpec = item.getAnimationSpec(index)
+            if (currentTarget != currentOffset) {
+                placeableInfo.targetOffset = currentOffset - itemInfo.notAnimatableDelta
+                if (animationSpec != null) {
+                    placeableInfo.inProgress = true
+                    scope.launch {
+                        val finalSpec = if (placeableInfo.animatedOffset.isRunning) {
+                            // when interrupted, use the default spring, unless the spec is a spring.
+                            if (animationSpec is SpringSpec<IntOffset>) animationSpec else
+                                InterruptionSpec
+                        } else {
+                            animationSpec
+                        }
+
+                        try {
+                            placeableInfo.animatedOffset.animateTo(
+                                placeableInfo.targetOffset,
+                                finalSpec
+                            )
+                            placeableInfo.inProgress = false
+                        } catch (_: CancellationException) {
+                            // we don't reset inProgress in case of cancellation as it means
+                            // there is a new animation started which would reset it later
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun Int.toOffset() =
+        IntOffset(if (isVertical) 0 else this, if (!isVertical) 0 else this)
+
+    private val IntOffset.mainAxis get() = if (isVertical) y else x
+}
+
+private class ItemInfo(var index: Int) {
+    var notAnimatableDelta: IntOffset = IntOffset.Zero
+    val placeables = mutableListOf<PlaceableInfo>()
+}
+
+private class PlaceableInfo(
+    initialOffset: IntOffset,
+    var size: Int
+) {
+    val animatedOffset = Animatable(initialOffset, IntOffset.VectorConverter)
+    var targetOffset: IntOffset = initialOffset
+    var inProgress by mutableStateOf(false)
+}
+
+/**
+ * We switch to this spec when a duration based animation is being interrupted.
+ */
+private val InterruptionSpec = spring(
+    stiffness = Spring.StiffnessMediumLow,
+    visibilityThreshold = IntOffset.VisibilityThreshold
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
new file mode 100644
index 0000000..7d4137f
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal sealed interface LazyListItemProvider : LazyLayoutItemProvider {
+    /** The list of indexes of the sticky header items */
+    val headerIndexes: List<Int>
+    /** The scope used by the item content lambdas */
+    val itemScope: TvLazyListItemScopeImpl
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemsProviderImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemsProviderImpl.kt
new file mode 100644
index 0000000..1e9966d
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemsProviderImpl.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.IntervalList
+import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.Snapshot
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+@Composable
+internal fun rememberItemProvider(
+    state: TvLazyListState,
+    content: TvLazyListScope.() -> Unit
+): LazyListItemProvider {
+    val latestContent = rememberUpdatedState(content)
+    // mutableState + LaunchedEffect below are used instead of derivedStateOf to ensure that update
+    // of derivedState in return expr will only happen after the state value has been changed.
+    val nearestItemsRangeState = remember(state) {
+        mutableStateOf(
+            Snapshot.withoutReadObservation {
+                // State read is observed in composition, causing it to recompose 1 additional time.
+                calculateNearestItemsRange(state.firstVisibleItemIndex)
+            }
+        )
+    }
+
+    LaunchedEffect(nearestItemsRangeState) {
+        snapshotFlow { calculateNearestItemsRange(state.firstVisibleItemIndex) }
+            // MutableState's SnapshotMutationPolicy will make sure the provider is only
+            // recreated when the state is updated with a new range.
+            .collect { nearestItemsRangeState.value = it }
+    }
+    return remember(nearestItemsRangeState) {
+        LazyListItemProviderImpl(
+            derivedStateOf {
+                val listScope = TvLazyListScopeImpl().apply(latestContent.value)
+                LazyListItemsSnapshot(
+                    listScope.intervals,
+                    listScope.headerIndexes,
+                    nearestItemsRangeState.value
+                )
+            }
+        )
+    }
+}
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal class LazyListItemsSnapshot(
+    private val intervals: IntervalList<LazyListIntervalContent>,
+    val headerIndexes: List<Int>,
+    nearestItemsRange: IntRange
+) {
+    val itemsCount get() = intervals.size
+
+    fun getKey(index: Int): Any {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        val key = interval.value.key?.invoke(localIntervalIndex)
+        return key ?: getDefaultLazyLayoutKey(index)
+    }
+
+    @Composable
+    fun Item(scope: TvLazyListItemScope, index: Int) {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        interval.value.item.invoke(scope, localIntervalIndex)
+    }
+
+    val keyToIndexMap: Map<Any, Int> = generateKeyToIndexMap(nearestItemsRange, intervals)
+
+    fun getContentType(index: Int): Any? {
+        val interval = intervals[index]
+        val localIntervalIndex = index - interval.startIndex
+        return interval.value.type.invoke(localIntervalIndex)
+    }
+}
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@ExperimentalFoundationApi
+internal class LazyListItemProviderImpl(
+    private val itemsSnapshot: State<LazyListItemsSnapshot>
+) : LazyListItemProvider {
+
+    override val itemScope = TvLazyListItemScopeImpl()
+
+    override val headerIndexes: List<Int> get() = itemsSnapshot.value.headerIndexes
+
+    override val itemCount get() = itemsSnapshot.value.itemsCount
+
+    override fun getKey(index: Int) = itemsSnapshot.value.getKey(index)
+
+    @Composable
+    override fun Item(index: Int) {
+        itemsSnapshot.value.Item(itemScope, index)
+    }
+
+    override val keyToIndexMap: Map<Any, Int> get() = itemsSnapshot.value.keyToIndexMap
+
+    override fun getContentType(index: Int) = itemsSnapshot.value.getContentType(index)
+}
+
+@ExperimentalFoundationApi
+internal fun generateKeyToIndexMap(
+    range: IntRange,
+    list: IntervalList<LazyListIntervalContent>
+): Map<Any, Int> {
+    val first = range.first
+    check(first >= 0)
+    val last = minOf(range.last, list.size - 1)
+    return if (last < first) {
+        emptyMap()
+    } else {
+        hashMapOf<Any, Int>().also { map ->
+            list.forEach(
+                fromIndex = first,
+                toIndex = last,
+            ) {
+                if (it.value.key != null) {
+                    val keyFactory = requireNotNull(it.value.key)
+                    val start = maxOf(first, it.startIndex)
+                    val end = minOf(last, it.startIndex + it.size - 1)
+                    for (i in start..end) {
+                        map[keyFactory(i - it.startIndex)] = i
+                    }
+                }
+            }
+        }
+    }
+}
+
+/**
+ * Returns a range of indexes which contains at least [ExtraItemsNearTheSlidingWindow] items near
+ * the first visible item. It is optimized to return the same range for small changes in the
+ * firstVisibleItem value so we do not regenerate the map on each scroll.
+ */
+private fun calculateNearestItemsRange(firstVisibleItem: Int): IntRange {
+    val slidingWindowStart = VisibleItemsSlidingWindowSize *
+        (firstVisibleItem / VisibleItemsSlidingWindowSize)
+
+    val start = maxOf(slidingWindowStart - ExtraItemsNearTheSlidingWindow, 0)
+    val end = slidingWindowStart + VisibleItemsSlidingWindowSize + ExtraItemsNearTheSlidingWindow
+    return start until end
+}
+
+/**
+ * We use the idea of sliding window as an optimization, so user can scroll up to this number of
+ * items until we have to regenerate the key to index map.
+ */
+private val VisibleItemsSlidingWindowSize = 30
+
+/**
+ * The minimum amount of items near the current first visible item we want to have mapping for.
+ */
+private val ExtraItemsNearTheSlidingWindow = 100
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
new file mode 100644
index 0000000..01fbb22
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
@@ -0,0 +1,424 @@
+/*
+ * Copyright 2020 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.util.fastForEach
+import androidx.tv.foundation.lazy.LazyListBeyondBoundsInfo
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlin.math.sign
+
+/**
+ * Measures and calculates the positions for the requested items. The result is produced
+ * as a [LazyListMeasureResult] which contains all the calculations.
+ */
+internal fun measureLazyList(
+    itemsCount: Int,
+    itemProvider: LazyMeasuredItemProvider,
+    mainAxisAvailableSize: Int,
+    beforeContentPadding: Int,
+    afterContentPadding: Int,
+    firstVisibleItemIndex: DataIndex,
+    firstVisibleItemScrollOffset: Int,
+    scrollToBeConsumed: Float,
+    constraints: Constraints,
+    isVertical: Boolean,
+    headerIndexes: List<Int>,
+    verticalArrangement: Arrangement.Vertical?,
+    horizontalArrangement: Arrangement.Horizontal?,
+    reverseLayout: Boolean,
+    density: Density,
+    placementAnimator: LazyListItemPlacementAnimator,
+    beyondBoundsInfo: LazyListBeyondBoundsInfo,
+    layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
+): LazyListMeasureResult {
+    require(beforeContentPadding >= 0)
+    require(afterContentPadding >= 0)
+    if (itemsCount <= 0) {
+        // empty data set. reset the current scroll and report zero size
+        return LazyListMeasureResult(
+            firstVisibleItem = null,
+            firstVisibleItemScrollOffset = 0,
+            canScrollForward = false,
+            consumedScroll = 0f,
+            measureResult = layout(constraints.minWidth, constraints.minHeight) {},
+            visibleItemsInfo = emptyList(),
+            viewportStartOffset = -beforeContentPadding,
+            viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
+            totalItemsCount = 0,
+            reverseLayout = reverseLayout,
+            orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
+            afterContentPadding = afterContentPadding
+        )
+    } else {
+        var currentFirstItemIndex = firstVisibleItemIndex
+        var currentFirstItemScrollOffset = firstVisibleItemScrollOffset
+        if (currentFirstItemIndex.value >= itemsCount) {
+            // the data set has been updated and now we have less items that we were
+            // scrolled to before
+            currentFirstItemIndex = DataIndex(itemsCount - 1)
+            currentFirstItemScrollOffset = 0
+        }
+
+        // represents the real amount of scroll we applied as a result of this measure pass.
+        var scrollDelta = scrollToBeConsumed.roundToInt()
+
+        // applying the whole requested scroll offset. we will figure out if we can't consume
+        // all of it later
+        currentFirstItemScrollOffset -= scrollDelta
+
+        // if the current scroll offset is less than minimally possible
+        if (currentFirstItemIndex == DataIndex(0) && currentFirstItemScrollOffset < 0) {
+            scrollDelta += currentFirstItemScrollOffset
+            currentFirstItemScrollOffset = 0
+        }
+
+        // this will contain all the MeasuredItems representing the visible items
+        val visibleItems = mutableListOf<LazyMeasuredItem>()
+
+        // include the start padding so we compose items in the padding area. before starting
+        // scrolling forward we would remove it back
+        currentFirstItemScrollOffset -= beforeContentPadding
+
+        // define min and max offsets (min offset currently includes beforeContentPadding)
+        val minOffset = -beforeContentPadding
+        val maxOffset = mainAxisAvailableSize
+
+        // max of cross axis sizes of all visible items
+        var maxCrossAxis = 0
+
+        // we had scrolled backward or we compose items in the start padding area, which means
+        // items before current firstItemScrollOffset should be visible. compose them and update
+        // firstItemScrollOffset
+        while (currentFirstItemScrollOffset < 0 && currentFirstItemIndex > DataIndex(0)) {
+            val previous = DataIndex(currentFirstItemIndex.value - 1)
+            val measuredItem = itemProvider.getAndMeasure(previous)
+            visibleItems.add(0, measuredItem)
+            maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
+            currentFirstItemScrollOffset += measuredItem.sizeWithSpacings
+            currentFirstItemIndex = previous
+        }
+        // if we were scrolled backward, but there were not enough items before. this means
+        // not the whole scroll was consumed
+        if (currentFirstItemScrollOffset < minOffset) {
+            scrollDelta += currentFirstItemScrollOffset
+            currentFirstItemScrollOffset = minOffset
+        }
+
+        // neutralize previously added start padding as we stopped filling the before content padding
+        currentFirstItemScrollOffset += beforeContentPadding
+
+        var index = currentFirstItemIndex
+        val maxMainAxis = (maxOffset + afterContentPadding).coerceAtLeast(0)
+        var currentMainAxisOffset = -currentFirstItemScrollOffset
+
+        // first we need to skip items we already composed while composing backward
+        visibleItems.fastForEach {
+            index++
+            currentMainAxisOffset += it.sizeWithSpacings
+        }
+
+        // then composing visible items forward until we fill the whole viewport.
+        // we want to have at least one item in visibleItems even if in fact all the items are
+        // offscreen, this can happen if the content padding is larger than the available size.
+        while ((currentMainAxisOffset <= maxMainAxis || visibleItems.isEmpty()) &&
+            index.value < itemsCount
+        ) {
+            val measuredItem = itemProvider.getAndMeasure(index)
+            currentMainAxisOffset += measuredItem.sizeWithSpacings
+
+            if (currentMainAxisOffset <= minOffset && index.value != itemsCount - 1) {
+                // this item is offscreen and will not be placed. advance firstVisibleItemIndex
+                currentFirstItemIndex = index + 1
+                currentFirstItemScrollOffset -= measuredItem.sizeWithSpacings
+            } else {
+                maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
+                visibleItems.add(measuredItem)
+            }
+
+            index++
+        }
+
+        // we didn't fill the whole viewport with items starting from firstVisibleItemIndex.
+        // lets try to scroll back if we have enough items before firstVisibleItemIndex.
+        if (currentMainAxisOffset < maxOffset) {
+            val toScrollBack = maxOffset - currentMainAxisOffset
+            currentFirstItemScrollOffset -= toScrollBack
+            currentMainAxisOffset += toScrollBack
+            while (currentFirstItemScrollOffset < beforeContentPadding &&
+                currentFirstItemIndex > DataIndex(0)
+            ) {
+                val previousIndex = DataIndex(currentFirstItemIndex.value - 1)
+                val measuredItem = itemProvider.getAndMeasure(previousIndex)
+                visibleItems.add(0, measuredItem)
+                maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
+                currentFirstItemScrollOffset += measuredItem.sizeWithSpacings
+                currentFirstItemIndex = previousIndex
+            }
+            scrollDelta += toScrollBack
+            if (currentFirstItemScrollOffset < 0) {
+                scrollDelta += currentFirstItemScrollOffset
+                currentMainAxisOffset += currentFirstItemScrollOffset
+                currentFirstItemScrollOffset = 0
+            }
+        }
+
+        // report the amount of pixels we consumed. scrollDelta can be smaller than
+        // scrollToBeConsumed if there were not enough items to fill the offered space or it
+        // can be larger if items were resized, or if, for example, we were previously
+        // displaying the item 15, but now we have only 10 items in total in the data set.
+        val consumedScroll = if (scrollToBeConsumed.roundToInt().sign == scrollDelta.sign &&
+            abs(scrollToBeConsumed.roundToInt()) >= abs(scrollDelta)
+        ) {
+            scrollDelta.toFloat()
+        } else {
+            scrollToBeConsumed
+        }
+
+        // the initial offset for items from visibleItems list
+        val visibleItemsScrollOffset = -currentFirstItemScrollOffset
+        var firstItem = visibleItems.first()
+
+        // even if we compose items to fill before content padding we should ignore items fully
+        // located there for the state's scroll position calculation (first item + first offset)
+        if (beforeContentPadding > 0) {
+            for (i in visibleItems.indices) {
+                val size = visibleItems[i].sizeWithSpacings
+                if (currentFirstItemScrollOffset != 0 && size <= currentFirstItemScrollOffset &&
+                    i != visibleItems.lastIndex
+                ) {
+                    currentFirstItemScrollOffset -= size
+                    firstItem = visibleItems[i + 1]
+                } else {
+                    break
+                }
+            }
+        }
+
+        // Compose extra items before or after the visible items.
+        fun LazyListBeyondBoundsInfo.startIndex() = min(start, itemsCount - 1)
+        fun LazyListBeyondBoundsInfo.endIndex() = min(end, itemsCount - 1)
+        val extraItemsBefore =
+            if (beyondBoundsInfo.hasIntervals() &&
+                visibleItems.first().index > beyondBoundsInfo.startIndex()) {
+                mutableListOf<LazyMeasuredItem>().apply {
+                    for (i in visibleItems.first().index - 1 downTo beyondBoundsInfo.startIndex()) {
+                        add(itemProvider.getAndMeasure(DataIndex(i)))
+                    }
+                }
+            } else {
+                emptyList()
+            }
+        val extraItemsAfter =
+            if (beyondBoundsInfo.hasIntervals() &&
+                visibleItems.last().index < beyondBoundsInfo.endIndex()) {
+                mutableListOf<LazyMeasuredItem>().apply {
+                    for (i in visibleItems.last().index until beyondBoundsInfo.endIndex()) {
+                        add(itemProvider.getAndMeasure(DataIndex(i + 1)))
+                    }
+                }
+            } else {
+                emptyList()
+            }
+
+        val noExtraItems = firstItem == visibleItems.first() &&
+            extraItemsBefore.isEmpty() &&
+            extraItemsAfter.isEmpty()
+
+        val layoutWidth =
+            constraints.constrainWidth(if (isVertical) maxCrossAxis else currentMainAxisOffset)
+        val layoutHeight =
+            constraints.constrainHeight(if (isVertical) currentMainAxisOffset else maxCrossAxis)
+
+        val positionedItems = calculateItemsOffsets(
+            items = visibleItems,
+            extraItemsBefore = extraItemsBefore,
+            extraItemsAfter = extraItemsAfter,
+            layoutWidth = layoutWidth,
+            layoutHeight = layoutHeight,
+            finalMainAxisOffset = currentMainAxisOffset,
+            maxOffset = maxOffset,
+            itemsScrollOffset = visibleItemsScrollOffset,
+            isVertical = isVertical,
+            verticalArrangement = verticalArrangement,
+            horizontalArrangement = horizontalArrangement,
+            reverseLayout = reverseLayout,
+            density = density,
+        )
+
+        val headerItem = if (headerIndexes.isNotEmpty()) {
+            findOrComposeLazyListHeader(
+                composedVisibleItems = positionedItems,
+                itemProvider = itemProvider,
+                headerIndexes = headerIndexes,
+                beforeContentPadding = beforeContentPadding,
+                layoutWidth = layoutWidth,
+                layoutHeight = layoutHeight
+            )
+        } else {
+            null
+        }
+
+        placementAnimator.onMeasured(
+            consumedScroll = consumedScroll.toInt(),
+            layoutWidth = layoutWidth,
+            layoutHeight = layoutHeight,
+            reverseLayout = reverseLayout,
+            positionedItems = positionedItems,
+            itemProvider = itemProvider
+        )
+
+        return LazyListMeasureResult(
+            firstVisibleItem = firstItem,
+            firstVisibleItemScrollOffset = currentFirstItemScrollOffset,
+            canScrollForward = currentMainAxisOffset > maxOffset,
+            consumedScroll = consumedScroll,
+            measureResult = layout(layoutWidth, layoutHeight) {
+                positionedItems.fastForEach {
+                    if (it !== headerItem) {
+                        it.place(this)
+                    }
+                }
+                // the header item should be placed (drawn) after all other items
+                headerItem?.place(this)
+            },
+            viewportStartOffset = -beforeContentPadding,
+            viewportEndOffset = maxOffset + afterContentPadding,
+            visibleItemsInfo = if (noExtraItems) positionedItems else positionedItems.fastFilter {
+                (it.index >= visibleItems.first().index && it.index <= visibleItems.last().index) ||
+                    it === headerItem
+            },
+            totalItemsCount = itemsCount,
+            reverseLayout = reverseLayout,
+            orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
+            afterContentPadding = afterContentPadding
+        )
+    }
+}
+
+/**
+ * Calculates [LazyMeasuredItem]s offsets.
+ */
+private fun calculateItemsOffsets(
+    items: List<LazyMeasuredItem>,
+    extraItemsBefore: List<LazyMeasuredItem>,
+    extraItemsAfter: List<LazyMeasuredItem>,
+    layoutWidth: Int,
+    layoutHeight: Int,
+    finalMainAxisOffset: Int,
+    maxOffset: Int,
+    itemsScrollOffset: Int,
+    isVertical: Boolean,
+    verticalArrangement: Arrangement.Vertical?,
+    horizontalArrangement: Arrangement.Horizontal?,
+    reverseLayout: Boolean,
+    density: Density,
+): MutableList<LazyListPositionedItem> {
+    val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+    val hasSpareSpace = finalMainAxisOffset < minOf(mainAxisLayoutSize, maxOffset)
+    if (hasSpareSpace) {
+        check(itemsScrollOffset == 0)
+    }
+
+    val positionedItems =
+        ArrayList<LazyListPositionedItem>(items.size + extraItemsBefore.size + extraItemsAfter.size)
+
+    if (hasSpareSpace) {
+        require(extraItemsBefore.isEmpty() && extraItemsAfter.isEmpty())
+
+        val itemsCount = items.size
+        fun Int.reverseAware() =
+            if (!reverseLayout) this else itemsCount - this - 1
+
+        val sizes = IntArray(itemsCount) { index ->
+            items[index.reverseAware()].size
+        }
+        val offsets = IntArray(itemsCount) { 0 }
+        if (isVertical) {
+            with(requireNotNull(verticalArrangement)) {
+                density.arrange(mainAxisLayoutSize, sizes, offsets)
+            }
+        } else {
+            with(requireNotNull(horizontalArrangement)) {
+                // Enforces Ltr layout direction as it is mirrored with placeRelative later.
+                density.arrange(mainAxisLayoutSize, sizes, LayoutDirection.Ltr, offsets)
+            }
+        }
+
+        val reverseAwareOffsetIndices =
+            if (!reverseLayout) offsets.indices else offsets.indices.reversed()
+        for (index in reverseAwareOffsetIndices) {
+            val absoluteOffset = offsets[index]
+            // when reverseLayout == true, offsets are stored in the reversed order to items
+            val item = items[index.reverseAware()]
+            val relativeOffset = if (reverseLayout) {
+                // inverse offset to align with scroll direction for positioning
+                mainAxisLayoutSize - absoluteOffset - item.size
+            } else {
+                absoluteOffset
+            }
+            positionedItems.add(item.position(relativeOffset, layoutWidth, layoutHeight))
+        }
+    } else {
+        var currentMainAxis = itemsScrollOffset
+        extraItemsBefore.fastForEach {
+            currentMainAxis -= it.sizeWithSpacings
+            positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
+        }
+
+        currentMainAxis = itemsScrollOffset
+        items.fastForEach {
+            positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
+            currentMainAxis += it.sizeWithSpacings
+        }
+
+        extraItemsAfter.fastForEach {
+            positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
+            currentMainAxis += it.sizeWithSpacings
+        }
+    }
+    return positionedItems
+}
+
+/**
+ * Returns a list containing only elements matching the given [predicate].
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@OptIn(ExperimentalContracts::class)
+internal fun <T> List<T>.fastFilter(predicate: (T) -> Boolean): List<T> {
+    contract { callsInPlace(predicate) }
+    val target = ArrayList<T>(size)
+    fastForEach {
+        if (predicate(it)) target += (it)
+    }
+    return target
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt
new file mode 100644
index 0000000..16902ec
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * The result of the measure pass for lazy list layout.
+ */
+internal class LazyListMeasureResult(
+    // properties defining the scroll position:
+    /** The new first visible item.*/
+    val firstVisibleItem: LazyMeasuredItem?,
+    /** The new value for [TvLazyListState.firstVisibleItemScrollOffset].*/
+    val firstVisibleItemScrollOffset: Int,
+    /** True if there is some space available to continue scrolling in the forward direction.*/
+    val canScrollForward: Boolean,
+    /** The amount of scroll consumed during the measure pass.*/
+    val consumedScroll: Float,
+    /** MeasureResult defining the layout.*/
+    measureResult: MeasureResult,
+    // properties representing the info needed for LazyListLayoutInfo:
+    /** see [TvLazyListLayoutInfo.visibleItemsInfo] */
+    override val visibleItemsInfo: List<TvLazyListItemInfo>,
+    /** see [TvLazyListLayoutInfo.viewportStartOffset] */
+    override val viewportStartOffset: Int,
+    /** see [TvLazyListLayoutInfo.viewportEndOffset] */
+    override val viewportEndOffset: Int,
+    /** see [TvLazyListLayoutInfo.totalItemsCount] */
+    override val totalItemsCount: Int,
+    /** see [TvLazyListLayoutInfo.reverseLayout] */
+    override val reverseLayout: Boolean,
+    /** see [TvLazyListLayoutInfo.orientation] */
+    override val orientation: Orientation,
+    /** see [TvLazyListLayoutInfo.afterContentPadding] */
+    override val afterContentPadding: Int
+) : TvLazyListLayoutInfo, MeasureResult by measureResult {
+    override val viewportSize: IntSize
+        get() = IntSize(width, height)
+    override val beforeContentPadding: Int get() = -viewportStartOffset
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt
new file mode 100644
index 0000000..8242e4a
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+
+/**
+ * Contains the current scroll position represented by the first visible item index and the first
+ * visible item scroll offset.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+internal class LazyListScrollPosition(
+    initialIndex: Int = 0,
+    initialScrollOffset: Int = 0
+) {
+    var index by mutableStateOf(DataIndex(initialIndex))
+
+    var scrollOffset by mutableStateOf(initialScrollOffset)
+        private set
+
+    private var hadFirstNotEmptyLayout = false
+
+    /** The last know key of the item at [index] position. */
+    private var lastKnownFirstItemKey: Any? = null
+
+    /**
+     * Updates the current scroll position based on the results of the last measurement.
+     */
+    fun updateFromMeasureResult(measureResult: LazyListMeasureResult) {
+        lastKnownFirstItemKey = measureResult.firstVisibleItem?.key
+        // we ignore the index and offset from measureResult until we get at least one
+        // measurement with real items. otherwise the initial index and scroll passed to the
+        // state would be lost and overridden with zeros.
+        if (hadFirstNotEmptyLayout || measureResult.totalItemsCount > 0) {
+            hadFirstNotEmptyLayout = true
+            val scrollOffset = measureResult.firstVisibleItemScrollOffset
+            check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" }
+            Snapshot.withoutReadObservation {
+                update(
+                    DataIndex(measureResult.firstVisibleItem?.index ?: 0),
+                    scrollOffset
+                )
+            }
+        }
+    }
+
+    /**
+     * Updates the scroll position - the passed values will be used as a start position for
+     * composing the items during the next measure pass and will be updated by the real
+     * position calculated during the measurement. This means that there is no guarantee that
+     * exactly this index and offset will be applied as it is possible that:
+     * a) there will be no item at this index in reality
+     * b) item at this index will be smaller than the asked scrollOffset, which means we would
+     * switch to the next item
+     * c) there will be not enough items to fill the viewport after the requested index, so we
+     * would have to compose few elements before the asked index, changing the first visible item.
+     */
+    fun requestPosition(index: DataIndex, scrollOffset: Int) {
+        update(index, scrollOffset)
+        // clear the stored key as we have a direct request to scroll to [index] position and the
+        // next [checkIfFirstVisibleItemWasMoved] shouldn't override this.
+        lastKnownFirstItemKey = null
+    }
+
+    /**
+     * In addition to keeping the first visible item index we also store the key of this item.
+     * When the user provided custom keys for the items this mechanism allows us to detect when
+     * there were items added or removed before our current first visible item and keep this item
+     * as the first visible one even given that its index has been changed.
+     */
+    @Suppress("IllegalExperimentalApiUsage") // TODO(b/233188423): Address before moving to beta
+    @ExperimentalFoundationApi
+    fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyListItemProvider) {
+        Snapshot.withoutReadObservation {
+            update(findLazyListIndexByKey(lastKnownFirstItemKey, index, itemProvider), scrollOffset)
+        }
+    }
+
+    private fun update(index: DataIndex, scrollOffset: Int) {
+        require(index.value >= 0f) { "Index should be non-negative (${index.value})" }
+        if (index != this.index) {
+            this.index = index
+        }
+        if (scrollOffset != this.scrollOffset) {
+            this.scrollOffset = scrollOffset
+        }
+    }
+
+    private companion object {
+        /**
+         * Finds a position of the item with the given key in the lists. This logic allows us to
+         * detect when there were items added or removed before our current first item.
+         */
+        @ExperimentalFoundationApi
+        private fun findLazyListIndexByKey(
+            key: Any?,
+            lastKnownIndex: DataIndex,
+            itemProvider: LazyListItemProvider
+        ): DataIndex {
+            if (key == null) {
+                // there were no real item during the previous measure
+                return lastKnownIndex
+            }
+            if (lastKnownIndex.value < itemProvider.itemCount &&
+                key == itemProvider.getKey(lastKnownIndex.value)
+            ) {
+                // this item is still at the same index
+                return lastKnownIndex
+            }
+            val newIndex = itemProvider.keyToIndexMap[key]
+            if (newIndex != null) {
+                return DataIndex(newIndex)
+            }
+            // fallback to the previous index if we don't know the new index of the item
+            return lastKnownIndex
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrolling.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrolling.kt
new file mode 100644
index 0000000..b78decf
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrolling.kt
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.animateTo
+import androidx.compose.animation.core.copy
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastSumBy
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.abs
+
+private class ItemFoundInScroll(
+    val item: TvLazyListItemInfo,
+    val previousAnimation: AnimationState<Float, AnimationVector1D>
+) : CancellationException()
+
+private val TargetDistance = 2500.dp
+private val BoundDistance = 1500.dp
+
+private const val DEBUG = false
+private inline fun debugLog(generateMsg: () -> String) {
+    if (DEBUG) {
+        println("LazyListScrolling: ${generateMsg()}")
+    }
+}
+
+internal suspend fun TvLazyListState.doSmoothScrollToItem(
+    index: Int,
+    scrollOffset: Int
+) {
+    require(index >= 0f) { "Index should be non-negative ($index)" }
+    fun getTargetItem() = layoutInfo.visibleItemsInfo.fastFirstOrNull {
+        it.index == index
+    }
+    scroll {
+        try {
+            val targetDistancePx = with(density) { TargetDistance.toPx() }
+            val boundDistancePx = with(density) { BoundDistance.toPx() }
+            var loop = true
+            var anim = AnimationState(0f)
+            val targetItemInitialInfo = getTargetItem()
+            if (targetItemInitialInfo != null) {
+                // It's already visible, just animate directly
+                throw ItemFoundInScroll(targetItemInitialInfo, anim)
+            }
+            val forward = index > firstVisibleItemIndex
+
+            fun isOvershot(): Boolean {
+                // Did we scroll past the item?
+                @Suppress("RedundantIf") // It's way easier to understand the logic this way
+                return if (forward) {
+                    if (firstVisibleItemIndex > index) {
+                        true
+                    } else if (
+                        firstVisibleItemIndex == index &&
+                        firstVisibleItemScrollOffset > scrollOffset
+                    ) {
+                        true
+                    } else {
+                        false
+                    }
+                } else { // backward
+                    if (firstVisibleItemIndex < index) {
+                        true
+                    } else if (
+                        firstVisibleItemIndex == index &&
+                        firstVisibleItemScrollOffset < scrollOffset
+                    ) {
+                        true
+                    } else {
+                        false
+                    }
+                }
+            }
+
+            var loops = 1
+            while (loop && layoutInfo.totalItemsCount > 0) {
+                val visibleItems = layoutInfo.visibleItemsInfo
+                val averageSize = visibleItems.fastSumBy { it.size } / visibleItems.size
+                val indexesDiff = index - firstVisibleItemIndex
+                val expectedDistance = (averageSize * indexesDiff).toFloat() +
+                    scrollOffset - firstVisibleItemScrollOffset
+                val target = if (abs(expectedDistance) < targetDistancePx) {
+                    expectedDistance
+                } else {
+                    if (forward) targetDistancePx else -targetDistancePx
+                }
+
+                debugLog {
+                    "Scrolling to index=$index offset=$scrollOffset from " +
+                        "index=$firstVisibleItemIndex offset=$firstVisibleItemScrollOffset with " +
+                        "averageSize=$averageSize and calculated target=$target"
+                }
+
+                anim = anim.copy(value = 0f)
+                var prevValue = 0f
+                anim.animateTo(
+                    target,
+                    sequentialAnimation = (anim.velocity != 0f)
+                ) {
+                    // If we haven't found the item yet, check if it's visible.
+                    var targetItem = getTargetItem()
+
+                    if (targetItem == null) {
+                        // Springs can overshoot their target, clamp to the desired range
+                        val coercedValue = if (target > 0) {
+                            value.coerceAtMost(target)
+                        } else {
+                            value.coerceAtLeast(target)
+                        }
+                        val delta = coercedValue - prevValue
+                        debugLog {
+                            "Scrolling by $delta (target: $target, coercedValue: $coercedValue)"
+                        }
+
+                        val consumed = scrollBy(delta)
+                        targetItem = getTargetItem()
+                        if (targetItem != null) {
+                            debugLog { "Found the item after performing scrollBy()" }
+                        } else if (!isOvershot()) {
+                            if (delta != consumed) {
+                                debugLog { "Hit end without finding the item" }
+                                cancelAnimation()
+                                loop = false
+                                return@animateTo
+                            }
+                            prevValue += delta
+                            if (forward) {
+                                if (value > boundDistancePx) {
+                                    debugLog { "Struck bound going forward" }
+                                    cancelAnimation()
+                                }
+                            } else {
+                                if (value < -boundDistancePx) {
+                                    debugLog { "Struck bound going backward" }
+                                    cancelAnimation()
+                                }
+                            }
+
+                            // Magic constants for teleportation chosen arbitrarily by experiment
+                            if (forward) {
+                                if (
+                                    loops >= 2 &&
+                                    index - layoutInfo.visibleItemsInfo.last().index > 100
+                                ) {
+                                    // Teleport
+                                    debugLog { "Teleport forward" }
+                                    snapToItemIndexInternal(index = index - 100, scrollOffset = 0)
+                                }
+                            } else {
+                                if (
+                                    loops >= 2 &&
+                                    layoutInfo.visibleItemsInfo.first().index - index > 100
+                                ) {
+                                    // Teleport
+                                    debugLog { "Teleport backward" }
+                                    snapToItemIndexInternal(index = index + 100, scrollOffset = 0)
+                                }
+                            }
+                        }
+                    }
+
+                    // We don't throw ItemFoundInScroll when we snap, because once we've snapped to
+                    // the final position, there's no need to animate to it.
+                    if (isOvershot()) {
+                        debugLog { "Overshot" }
+                        snapToItemIndexInternal(index = index, scrollOffset = scrollOffset)
+                        loop = false
+                        cancelAnimation()
+                        return@animateTo
+                    } else if (targetItem != null) {
+                        debugLog { "Found item" }
+                        throw ItemFoundInScroll(targetItem, anim)
+                    }
+                }
+
+                loops++
+            }
+        } catch (itemFound: ItemFoundInScroll) {
+            // We found it, animate to it
+            // Bring to the requested position - will be automatically stopped if not possible
+            val anim = itemFound.previousAnimation.copy(value = 0f)
+            val target = (itemFound.item.offset + scrollOffset).toFloat()
+            var prevValue = 0f
+            debugLog {
+                "Seeking by $target at velocity ${itemFound.previousAnimation.velocity}"
+            }
+            anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) {
+                // Springs can overshoot their target, clamp to the desired range
+                val coercedValue = when {
+                    target > 0 -> {
+                        value.coerceAtMost(target)
+                    }
+                    target < 0 -> {
+                        value.coerceAtLeast(target)
+                    }
+                    else -> {
+                        debugLog { "WARNING: somehow ended up seeking 0px, this shouldn't happen" }
+                        0f
+                    }
+                }
+                val delta = coercedValue - prevValue
+                debugLog { "Seeking by $delta (coercedValue = $coercedValue)" }
+                val consumed = scrollBy(delta)
+                if (delta != consumed /* hit the end, stop */ ||
+                    coercedValue != value /* would have overshot, stop */
+                ) {
+                    cancelAnimation()
+                }
+                prevValue += delta
+            }
+            // Once we're finished the animation, snap to the exact position to account for
+            // rounding error (otherwise we tend to end up with the previous item scrolled the
+            // tiniest bit onscreen)
+            // TODO: prevent temporarily scrolling *past* the item
+            snapToItemIndexInternal(index = index, scrollOffset = scrollOffset)
+        }
+    }
+}
\ No newline at end of file
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
new file mode 100644
index 0000000..37862be
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
@@ -0,0 +1,429 @@
+/*
+ * Copyright 2020 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.ScrollScope
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.OnGloballyPositionedModifier
+import androidx.compose.ui.layout.Remeasurement
+import androidx.compose.ui.layout.RemeasurementModifier
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+import kotlin.math.abs
+
+/**
+ * Creates a [TvLazyListState] that is remembered across compositions.
+ *
+ * Changes to the provided initial values will **not** result in the state being recreated or
+ * changed in any way if it has already been created.
+ *
+ * @param initialFirstVisibleItemIndex the initial value for [TvLazyListState.firstVisibleItemIndex]
+ * @param initialFirstVisibleItemScrollOffset the initial value for
+ * [TvLazyListState.firstVisibleItemScrollOffset]
+ */
+@Composable
+fun rememberTvLazyListState(
+    initialFirstVisibleItemIndex: Int = 0,
+    initialFirstVisibleItemScrollOffset: Int = 0
+): TvLazyListState {
+    return rememberSaveable(saver = TvLazyListState.Saver) {
+        TvLazyListState(
+            initialFirstVisibleItemIndex,
+            initialFirstVisibleItemScrollOffset
+        )
+    }
+}
+
+/**
+ * A state object that can be hoisted to control and observe scrolling.
+ *
+ * In most cases, this will be created via [rememberTvLazyListState].
+ *
+ * @param firstVisibleItemIndex the initial value for [TvLazyListState.firstVisibleItemIndex]
+ * @param firstVisibleItemScrollOffset the initial value for
+ * [TvLazyListState.firstVisibleItemScrollOffset]
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Stable
+class TvLazyListState constructor(
+    firstVisibleItemIndex: Int = 0,
+    firstVisibleItemScrollOffset: Int = 0
+) : ScrollableState {
+    /**
+     * The holder class for the current scroll position.
+     */
+    private val scrollPosition =
+        LazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)
+
+    /**
+     * The index of the first item that is visible.
+     *
+     * Note that this property is observable and if you use it in the composable function it will
+     * be recomposed on every change causing potential performance issues.
+     *
+     * If you want to run some side effects like sending an analytics event or updating a state
+     * based on this value consider using "snapshotFlow":
+     * @sample androidx.compose.foundation.samples.UsingListScrollPositionForSideEffectSample
+     *
+     * If you need to use it in the composition then consider wrapping the calculation into a
+     * derived state in order to only have recompositions when the derived value changes:
+     * @sample androidx.compose.foundation.samples.UsingListScrollPositionInCompositionSample
+     */
+    val firstVisibleItemIndex: Int get() = scrollPosition.index.value
+
+    /**
+     * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the
+     * amount that the item is offset backwards.
+     *
+     * Note that this property is observable and if you use it in the composable function it will
+     * be recomposed on every scroll causing potential performance issues.
+     * @see firstVisibleItemIndex for samples with the recommended usage patterns.
+     */
+    val firstVisibleItemScrollOffset: Int get() = scrollPosition.scrollOffset
+
+    /** Backing state for [layoutInfo] */
+    private val layoutInfoState = mutableStateOf<TvLazyListLayoutInfo>(EmptyLazyListLayoutInfo)
+
+    /**
+     * The object of [TvLazyListLayoutInfo] calculated during the last layout pass. For example,
+     * you can use it to calculate what items are currently visible.
+     *
+     * Note that this property is observable and is updated after every scroll or remeasure.
+     * If you use it in the composable function it will be recomposed on every change causing
+     * potential performance issues including infinity recomposition loop.
+     * Therefore, avoid using it in the composition.
+     *
+     * If you want to run some side effects like sending an analytics event or updating a state
+     * based on this value consider using "snapshotFlow":
+     * @sample androidx.compose.foundation.samples.UsingListLayoutInfoForSideEffectSample
+     */
+    val layoutInfo: TvLazyListLayoutInfo get() = layoutInfoState.value
+
+    /**
+     * [InteractionSource] that will be used to dispatch drag events when this
+     * list is being dragged. If you want to know whether the fling (or animated scroll) is in
+     * progress, use [isScrollInProgress].
+     */
+    val interactionSource: InteractionSource get() = internalInteractionSource
+
+    internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
+
+    /**
+     * The amount of scroll to be consumed in the next layout pass.  Scrolling forward is negative
+     * - that is, it is the amount that the items are offset in y
+     */
+    internal var scrollToBeConsumed = 0f
+        private set
+
+    /**
+     * Needed for [animateScrollToItem].  Updated on every measure.
+     */
+    internal var density: Density by mutableStateOf(Density(1f, 1f))
+
+    /**
+     * The ScrollableController instance. We keep it as we need to call stopAnimation on it once
+     * we reached the end of the list.
+     */
+    private val scrollableState = ScrollableState { -onScroll(-it) }
+
+    /**
+     * Only used for testing to confirm that we're not making too many measure passes
+     */
+    /*@VisibleForTesting*/
+    internal var numMeasurePasses: Int = 0
+        private set
+
+    /**
+     * Only used for testing to disable prefetching when needed to test the main logic.
+     */
+    /*@VisibleForTesting*/
+    internal var prefetchingEnabled: Boolean = true
+
+    /**
+     * The index scheduled to be prefetched (or the last prefetched index if the prefetch is done).
+     */
+    private var indexToPrefetch = -1
+
+    /**
+     * The handle associated with the current index from [indexToPrefetch].
+     */
+    private var currentPrefetchHandle: LazyLayoutPrefetchState.PrefetchHandle? = null
+
+    /**
+     * Keeps the scrolling direction during the previous calculation in order to be able to
+     * detect the scrolling direction change.
+     */
+    private var wasScrollingForward = false
+
+    /**
+     * The [Remeasurement] object associated with our layout. It allows us to remeasure
+     * synchronously during scroll.
+     */
+    internal var remeasurement: Remeasurement? by mutableStateOf(null)
+        private set
+    /**
+     * The modifier which provides [remeasurement].
+     */
+    internal val remeasurementModifier = object : RemeasurementModifier {
+        override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
+            this@TvLazyListState.remeasurement = remeasurement
+        }
+    }
+
+    /**
+     * Provides a modifier which allows to delay some interactions (e.g. scroll)
+     * until layout is ready.
+     */
+    internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
+
+    internal var placementAnimator by mutableStateOf<LazyListItemPlacementAnimator?>(null)
+
+    /**
+     * Constraints passed to the prefetcher for premeasuring the prefetched items.
+     */
+    internal var premeasureConstraints by mutableStateOf(Constraints())
+
+    /**
+     * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
+     * pixels.
+     *
+     * @param index the index to which to scroll. Must be non-negative.
+     * @param scrollOffset the offset that the item should end up after the scroll. Note that
+     * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
+     * scroll the item further upward (taking it partly offscreen).
+     */
+    suspend fun scrollToItem(
+        /*@IntRange(from = 0)*/
+        index: Int,
+        scrollOffset: Int = 0
+    ) {
+        scroll {
+            snapToItemIndexInternal(index, scrollOffset)
+        }
+    }
+
+    internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
+        scrollPosition.requestPosition(DataIndex(index), scrollOffset)
+        // placement animation is not needed because we snap into a new position.
+        placementAnimator?.reset()
+        remeasurement?.forceRemeasure()
+    }
+
+    /**
+     * Call this function to take control of scrolling and gain the ability to send scroll events
+     * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be
+     * performed within a [scroll] block (even if they don't call any other methods on this
+     * object) in order to guarantee that mutual exclusion is enforced.
+     *
+     * If [scroll] is called from elsewhere, this will be canceled.
+     */
+    override suspend fun scroll(
+        scrollPriority: MutatePriority,
+        block: suspend ScrollScope.() -> Unit
+    ) {
+        awaitLayoutModifier.waitForFirstLayout()
+        scrollableState.scroll(scrollPriority, block)
+    }
+
+    override fun dispatchRawDelta(delta: Float): Float =
+        scrollableState.dispatchRawDelta(delta)
+
+    override val isScrollInProgress: Boolean
+        get() = scrollableState.isScrollInProgress
+
+    private var canScrollBackward: Boolean = false
+    internal var canScrollForward: Boolean = false
+        private set
+
+    // TODO: Coroutine scrolling APIs will allow this to be private again once we have more
+    //  fine-grained control over scrolling
+    /*@VisibleForTesting*/
+    internal fun onScroll(distance: Float): Float {
+        if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) {
+            return 0f
+        }
+        check(abs(scrollToBeConsumed) <= 0.5f) {
+            "entered drag with non-zero pending scroll: $scrollToBeConsumed"
+        }
+        scrollToBeConsumed += distance
+
+        // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation
+        // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if
+        // we have less than 0.5 pixels
+        if (abs(scrollToBeConsumed) > 0.5f) {
+            val preScrollToBeConsumed = scrollToBeConsumed
+            remeasurement?.forceRemeasure()
+            if (prefetchingEnabled) {
+                notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumed)
+            }
+        }
+
+        // here scrollToBeConsumed is already consumed during the forceRemeasure invocation
+        if (abs(scrollToBeConsumed) <= 0.5f) {
+            // We consumed all of it - we'll hold onto the fractional scroll for later, so report
+            // that we consumed the whole thing
+            return distance
+        } else {
+            val scrollConsumed = distance - scrollToBeConsumed
+            // We did not consume all of it - return the rest to be consumed elsewhere (e.g.,
+            // nested scrolling)
+            scrollToBeConsumed = 0f // We're not consuming the rest, give it back
+            return scrollConsumed
+        }
+    }
+
+    private fun notifyPrefetch(delta: Float) {
+        if (!prefetchingEnabled) {
+            return
+        }
+        val info = layoutInfo
+        if (info.visibleItemsInfo.isNotEmpty()) {
+            // check(isActive)
+            val scrollingForward = delta < 0
+            val indexToPrefetch = if (scrollingForward) {
+                info.visibleItemsInfo.last().index + 1
+            } else {
+                info.visibleItemsInfo.first().index - 1
+            }
+            if (indexToPrefetch != this.indexToPrefetch &&
+                indexToPrefetch in 0 until info.totalItemsCount
+            ) {
+                if (wasScrollingForward != scrollingForward) {
+                    // the scrolling direction has been changed which means the last prefetched
+                    // is not going to be reached anytime soon so it is safer to dispose it.
+                    // if this item is already visible it is safe to call the method anyway
+                    // as it will be no-op
+                    currentPrefetchHandle?.cancel()
+                }
+                this.wasScrollingForward = scrollingForward
+                this.indexToPrefetch = indexToPrefetch
+                currentPrefetchHandle = prefetchState.schedulePrefetch(
+                    indexToPrefetch, premeasureConstraints
+                )
+            }
+        }
+    }
+
+    internal val prefetchState = LazyLayoutPrefetchState()
+
+    /**
+     * Animate (smooth scroll) to the given item.
+     *
+     * @param index the index to which to scroll. Must be non-negative.
+     * @param scrollOffset the offset that the item should end up after the scroll. Note that
+     * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
+     * scroll the item further upward (taking it partly offscreen).
+     */
+    suspend fun animateScrollToItem(
+        /*@IntRange(from = 0)*/
+        index: Int,
+        scrollOffset: Int = 0
+    ) {
+        doSmoothScrollToItem(index, scrollOffset)
+    }
+
+    /**
+     *  Updates the state with the new calculated scroll position and consumed scroll.
+     */
+    internal fun applyMeasureResult(result: LazyListMeasureResult) {
+        scrollPosition.updateFromMeasureResult(result)
+        scrollToBeConsumed -= result.consumedScroll
+        layoutInfoState.value = result
+
+        canScrollForward = result.canScrollForward
+        canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 ||
+            result.firstVisibleItemScrollOffset != 0
+
+        numMeasurePasses++
+    }
+
+    /**
+     * When the user provided custom keys for the items we can try to detect when there were
+     * items added or removed before our current first visible item and keep this item
+     * as the first visible one even given that its index has been changed.
+     */
+    internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyListItemProvider) {
+        scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+    }
+
+    companion object {
+        /**
+         * The default [Saver] implementation for [TvLazyListState].
+         */
+        val Saver: Saver<TvLazyListState, *> = listSaver(
+            save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
+            restore = {
+                TvLazyListState(
+                    firstVisibleItemIndex = it[0],
+                    firstVisibleItemScrollOffset = it[1]
+                )
+            }
+        )
+    }
+}
+
+private object EmptyLazyListLayoutInfo : TvLazyListLayoutInfo {
+    override val visibleItemsInfo = emptyList<TvLazyListItemInfo>()
+    override val viewportStartOffset = 0
+    override val viewportEndOffset = 0
+    override val totalItemsCount = 0
+    override val viewportSize = IntSize.Zero
+    override val orientation = Orientation.Vertical
+    override val reverseLayout = false
+    override val beforeContentPadding = 0
+    override val afterContentPadding = 0
+}
+
+internal class AwaitFirstLayoutModifier : OnGloballyPositionedModifier {
+    private var wasPositioned = false
+    private var continuation: Continuation<Unit>? = null
+
+    suspend fun waitForFirstLayout() {
+        if (!wasPositioned) {
+            val oldContinuation = continuation
+            suspendCoroutine<Unit> { continuation = it }
+            oldContinuation?.resume(Unit)
+        }
+    }
+
+    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
+        if (!wasPositioned) {
+            wasPositioned = true
+            continuation?.resume(Unit)
+            continuation = null
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt
new file mode 100644
index 0000000..9698c79
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2020 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * Represents one measured item of the lazy list. It can in fact consist of multiple placeables
+ * if the user emit multiple layout nodes in the item callback.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+internal class LazyMeasuredItem @ExperimentalFoundationApi constructor(
+    val index: Int,
+    private val placeables: Array<Placeable>,
+    private val isVertical: Boolean,
+    private val horizontalAlignment: Alignment.Horizontal?,
+    private val verticalAlignment: Alignment.Vertical?,
+    private val layoutDirection: LayoutDirection,
+    private val reverseLayout: Boolean,
+    private val beforeContentPadding: Int,
+    private val afterContentPadding: Int,
+    private val placementAnimator: LazyListItemPlacementAnimator,
+    /**
+     * Extra spacing to be added to [size] aside from the sum of the [placeables] size. It
+     * is usually representing the spacing after the item.
+     */
+    private val spacing: Int,
+    /**
+     * The offset which shouldn't affect any calculations but needs to be applied for the final
+     * value passed into the place() call.
+     */
+    private val visualOffset: IntOffset,
+    val key: Any,
+) {
+    /**
+     * Sum of the main axis sizes of all the inner placeables.
+     */
+    val size: Int
+
+    /**
+     * Sum of the main axis sizes of all the inner placeables and [spacing].
+     */
+    val sizeWithSpacings: Int
+
+    /**
+     * Max of the cross axis sizes of all the inner placeables.
+     */
+    val crossAxisSize: Int
+
+    init {
+        var mainAxisSize = 0
+        var maxCrossAxis = 0
+        placeables.forEach {
+            mainAxisSize += if (isVertical) it.height else it.width
+            maxCrossAxis = maxOf(maxCrossAxis, if (!isVertical) it.height else it.width)
+        }
+        size = mainAxisSize
+        sizeWithSpacings = size + spacing
+        crossAxisSize = maxCrossAxis
+    }
+
+    /**
+     * Calculates positions for the inner placeables at [offset] main axis position.
+     * If [reverseOrder] is true the inner placeables would be placed in the inverted order.
+     */
+    fun position(
+        offset: Int,
+        layoutWidth: Int,
+        layoutHeight: Int
+    ): LazyListPositionedItem {
+        val wrappers = mutableListOf<LazyListPlaceableWrapper>()
+        val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+        var mainAxisOffset = if (reverseLayout) {
+            mainAxisLayoutSize - offset - size
+        } else {
+            offset
+        }
+        var index = if (reverseLayout) placeables.lastIndex else 0
+        while (if (reverseLayout) index >= 0 else index < placeables.size) {
+            val it = placeables[index]
+            val addIndex = if (reverseLayout) 0 else wrappers.size
+            val placeableOffset = if (isVertical) {
+                val x = requireNotNull(horizontalAlignment)
+                    .align(it.width, layoutWidth, layoutDirection)
+                IntOffset(x, mainAxisOffset)
+            } else {
+                val y = requireNotNull(verticalAlignment).align(it.height, layoutHeight)
+                IntOffset(mainAxisOffset, y)
+            }
+            mainAxisOffset += if (isVertical) it.height else it.width
+            wrappers.add(
+                addIndex,
+                LazyListPlaceableWrapper(placeableOffset, it, placeables[index].parentData)
+            )
+            if (reverseLayout) index-- else index++
+        }
+        return LazyListPositionedItem(
+            offset = offset,
+            index = this.index,
+            key = key,
+            size = size,
+            sizeWithSpacings = sizeWithSpacings,
+            minMainAxisOffset = -if (!reverseLayout) beforeContentPadding else afterContentPadding,
+            maxMainAxisOffset = mainAxisLayoutSize +
+                if (!reverseLayout) afterContentPadding else beforeContentPadding,
+            isVertical = isVertical,
+            wrappers = wrappers,
+            placementAnimator = placementAnimator,
+            visualOffset = visualOffset
+        )
+    }
+}
+
+internal class LazyListPositionedItem(
+    override val offset: Int,
+    override val index: Int,
+    override val key: Any,
+    override val size: Int,
+    val sizeWithSpacings: Int,
+    private val minMainAxisOffset: Int,
+    private val maxMainAxisOffset: Int,
+    private val isVertical: Boolean,
+    private val wrappers: List<LazyListPlaceableWrapper>,
+    private val placementAnimator: LazyListItemPlacementAnimator,
+    private val visualOffset: IntOffset
+) : TvLazyListItemInfo {
+    val placeablesCount: Int get() = wrappers.size
+
+    fun getOffset(index: Int) = wrappers[index].offset
+
+    fun getMainAxisSize(index: Int) = wrappers[index].placeable.mainAxisSize
+
+    @Suppress("UNCHECKED_CAST")
+    fun getAnimationSpec(index: Int) =
+        wrappers[index].parentData as? FiniteAnimationSpec<IntOffset>?
+
+    val hasAnimations = run {
+        repeat(placeablesCount) { index ->
+            if (getAnimationSpec(index) != null) {
+                return@run true
+            }
+        }
+        false
+    }
+
+    fun place(
+        scope: Placeable.PlacementScope,
+    ) = with(scope) {
+        repeat(placeablesCount) { index ->
+            val placeable = wrappers[index].placeable
+            val minOffset = minMainAxisOffset - placeable.mainAxisSize
+            val maxOffset = maxMainAxisOffset
+            val offset = if (getAnimationSpec(index) != null) {
+                placementAnimator.getAnimatedOffset(
+                    key, index, minOffset, maxOffset, getOffset(index)
+                )
+            } else {
+                getOffset(index)
+            }
+            if (isVertical) {
+                placeable.placeWithLayer(offset + visualOffset)
+            } else {
+                placeable.placeRelativeWithLayer(offset + visualOffset)
+            }
+        }
+    }
+
+    private val Placeable.mainAxisSize get() = if (isVertical) height else width
+}
+
+internal class LazyListPlaceableWrapper(
+    val offset: IntOffset,
+    val placeable: Placeable,
+    val parentData: Any?
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt
new file mode 100644
index 0000000..2cd0037
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2020 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+
+/**
+ * Abstracts away the subcomposition from the measuring logic.
+ */
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyMeasuredItemProvider @ExperimentalFoundationApi constructor(
+    constraints: Constraints,
+    isVertical: Boolean,
+    private val itemProvider: LazyListItemProvider,
+    private val measureScope: LazyLayoutMeasureScope,
+    private val measuredItemFactory: MeasuredItemFactory
+) {
+    // the constraints we will measure child with. the main axis is not restricted
+    val childConstraints = Constraints(
+        maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity,
+        maxHeight = if (!isVertical) constraints.maxHeight else Constraints.Infinity
+    )
+
+    /**
+     * Used to subcompose items of lazy lists. Composed placeables will be measured with the
+     * correct constraints and wrapped into [LazyMeasuredItem].
+     */
+    fun getAndMeasure(index: DataIndex): LazyMeasuredItem {
+        val key = itemProvider.getKey(index.value)
+        val placeables = measureScope.measure(index.value, childConstraints)
+        return measuredItemFactory.createItem(index, key, placeables)
+    }
+
+    /**
+     * Contains the mapping between the key and the index. It could contain not all the items of
+     * the list as an optimization.
+     **/
+    val keyToIndexMap: Map<Any, Int> get() = itemProvider.keyToIndexMap
+}
+
+// This interface allows to avoid autoboxing on index param
+internal fun interface MeasuredItemFactory {
+    fun createItem(
+        index: DataIndex,
+        key: Any,
+        placeables: Array<Placeable>
+    ): LazyMeasuredItem
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt
new file mode 100644
index 0000000..1e3bb60
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.CollectionInfo
+import androidx.compose.ui.semantics.ScrollAxisRange
+import androidx.compose.ui.semantics.collectionInfo
+import androidx.compose.ui.semantics.horizontalScrollAxisRange
+import androidx.compose.ui.semantics.indexForKey
+import androidx.compose.ui.semantics.scrollBy
+import androidx.compose.ui.semantics.scrollToIndex
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.verticalScrollAxisRange
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+// TODO (b/233188423): Address IllegalExperimentalApiUsage before moving to beta
+@Suppress("ComposableModifierFactory", "ModifierInspectorInfo", "IllegalExperimentalApiUsage")
+@ExperimentalFoundationApi
+@Composable
+internal fun Modifier.lazyListSemantics(
+    itemProvider: LazyListItemProvider,
+    state: TvLazyListState,
+    coroutineScope: CoroutineScope,
+    isVertical: Boolean,
+    reverseScrolling: Boolean,
+    userScrollEnabled: Boolean
+) = this.then(
+    remember(
+        itemProvider,
+        state,
+        isVertical,
+        reverseScrolling,
+        userScrollEnabled
+    ) {
+        val indexForKeyMapping: (Any) -> Int = { needle ->
+            val key = itemProvider::getKey
+            var result = -1
+            for (index in 0 until itemProvider.itemCount) {
+                if (key(index) == needle) {
+                    result = index
+                    break
+                }
+            }
+            result
+        }
+
+        val accessibilityScrollState = ScrollAxisRange(
+            value = {
+                // This is a simple way of representing the current position without
+                // needing any lazy items to be measured. It's good enough so far, because
+                // screen-readers care mostly about whether scroll position changed or not
+                // rather than the actual offset in pixels.
+                state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+            },
+            maxValue = {
+                if (state.canScrollForward) {
+                    // If we can scroll further, we don't know the end yet,
+                    // but it's upper bounded by #items + 1
+                    itemProvider.itemCount + 1f
+                } else {
+                    // If we can't scroll further, the current value is the max
+                    state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+                }
+            },
+            reverseScrolling = reverseScrolling
+        )
+        val scrollByAction: ((x: Float, y: Float) -> Boolean)? = if (userScrollEnabled) {
+            { x, y ->
+                val delta = if (isVertical) {
+                    y
+                } else {
+                    x
+                }
+                coroutineScope.launch {
+                    (state as ScrollableState).animateScrollBy(delta)
+                }
+                // TODO(aelias): is it important to return false if we know in advance we cannot scroll?
+                true
+            }
+        } else {
+            null
+        }
+
+        val scrollToIndexAction: ((Int) -> Boolean)? = if (userScrollEnabled) {
+            { index ->
+                require(index >= 0 && index < state.layoutInfo.totalItemsCount) {
+                    "Can't scroll to index $index, it is out of " +
+                        "bounds [0, ${state.layoutInfo.totalItemsCount})"
+                }
+                coroutineScope.launch {
+                    state.scrollToItem(index)
+                }
+                true
+            }
+        } else {
+            null
+        }
+
+        val collectionInfo = CollectionInfo(
+            rowCount = if (isVertical) -1 else 1,
+            columnCount = if (isVertical) 1 else -1
+        )
+
+        Modifier.semantics {
+            indexForKey(indexForKeyMapping)
+
+            if (isVertical) {
+                verticalScrollAxisRange = accessibilityScrollState
+            } else {
+                horizontalScrollAxisRange = accessibilityScrollState
+            }
+
+            if (scrollByAction != null) {
+                scrollBy(action = scrollByAction)
+            }
+
+            if (scrollToIndexAction != null) {
+                scrollToIndex(action = scrollToIndexAction)
+            }
+
+            this.collectionInfo = collectionInfo
+        }
+    }
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt
new file mode 100644
index 0000000..55274f8
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+/**
+ * Contains useful information about an individual item in lazy lists like [TvLazyColumn]
+ *  or [TvLazyRow].
+ *
+ * @see TvLazyListLayoutInfo
+ */
+interface TvLazyListItemInfo {
+    /**
+     * The index of the item in the list.
+     */
+    val index: Int
+
+    /**
+     * The key of the item which was passed to the item() or items() function.
+     */
+    val key: Any
+
+    /**
+     * The main axis offset of the item in pixels. It is relative to the start of the lazy list container.
+     */
+    val offset: Int
+
+    /**
+     * The main axis size of the item in pixels. Note that if you emit multiple layouts in the composable
+     * slot for the item then this size will be calculated as the sum of their sizes.
+     */
+    val size: Int
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt
new file mode 100644
index 0000000..441895d
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.annotation.FloatRange
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntOffset
+
+@Stable
+@TvLazyListScopeMarker
+sealed interface TvLazyListItemScope {
+    /**
+     * Have the content fill the [Constraints.maxWidth] and [Constraints.maxHeight] of the parent
+     * measurement constraints by setting the [minimum width][Constraints.minWidth] to be equal to
+     * the [maximum width][Constraints.maxWidth] multiplied by [fraction] and the [minimum
+     * height][Constraints.minHeight] to be equal to the [maximum height][Constraints.maxHeight]
+     * multiplied by [fraction]. Note that, by default, the [fraction] is 1, so the modifier will
+     * make the content fill the whole available space. [fraction] must be between `0` and `1`.
+     *
+     * Regular [Modifier.fillMaxSize] can't work inside the scrolling layouts as the items are
+     * measured with [Constraints.Infinity] as the constraints for the main axis.
+     */
+    fun Modifier.fillParentMaxSize(
+        @FloatRange(from = 0.0, to = 1.0)
+        fraction: Float = 1f
+    ): Modifier
+
+    /**
+     * Have the content fill the [Constraints.maxWidth] of the parent measurement constraints
+     * by setting the [minimum width][Constraints.minWidth] to be equal to the
+     * [maximum width][Constraints.maxWidth] multiplied by [fraction]. Note that, by default, the
+     * [fraction] is 1, so the modifier will make the content fill the whole parent width.
+     * [fraction] must be between `0` and `1`.
+     *
+     * Regular [Modifier.fillMaxWidth] can't work inside the scrolling horizontally layouts as the
+     * items are measured with [Constraints.Infinity] as the constraints for the main axis.
+     */
+    fun Modifier.fillParentMaxWidth(
+        @FloatRange(from = 0.0, to = 1.0)
+        fraction: Float = 1f
+    ): Modifier
+
+    /**
+     * Have the content fill the [Constraints.maxHeight] of the incoming measurement constraints
+     * by setting the [minimum height][Constraints.minHeight] to be equal to the
+     * [maximum height][Constraints.maxHeight] multiplied by [fraction]. Note that, by default, the
+     * [fraction] is 1, so the modifier will make the content fill the whole parent height.
+     * [fraction] must be between `0` and `1`.
+     *
+     * Regular [Modifier.fillMaxHeight] can't work inside the scrolling vertically layouts as the
+     * items are measured with [Constraints.Infinity] as the constraints for the main axis.
+     */
+    fun Modifier.fillParentMaxHeight(
+        @FloatRange(from = 0.0, to = 1.0)
+        fraction: Float = 1f
+    ): Modifier
+
+    /**
+     * This modifier animates the item placement within the Lazy list.
+     *
+     * When you provide a key via [TvLazyListScope.item]/[TvLazyListScope.items] this modifier will
+     * enable item reordering animations. Aside from item reordering all other position changes
+     * caused by events like arrangement or alignment changes will also be animated.
+     *
+     * @sample androidx.compose.foundation.samples.ItemPlacementAnimationSample
+     *
+     * @param animationSpec a finite animation that will be used to animate the item placement.
+     */
+    @ExperimentalFoundationApi
+    fun Modifier.animateItemPlacement(
+        animationSpec: FiniteAnimationSpec<IntOffset> = spring(
+            stiffness = Spring.StiffnessMediumLow,
+            visibilityThreshold = IntOffset.VisibilityThreshold
+        )
+    ): Modifier
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt
new file mode 100644
index 0000000..64bd81c
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ParentDataModifier
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+
+internal class TvLazyListItemScopeImpl : TvLazyListItemScope {
+
+    var maxWidth: Dp by mutableStateOf(Dp.Unspecified)
+    var maxHeight: Dp by mutableStateOf(Dp.Unspecified)
+
+    override fun Modifier.fillParentMaxSize(fraction: Float) = size(
+        maxWidth * fraction,
+        maxHeight * fraction
+    )
+
+    override fun Modifier.fillParentMaxWidth(fraction: Float) =
+        width(maxWidth * fraction)
+
+    override fun Modifier.fillParentMaxHeight(fraction: Float) =
+        height(maxHeight * fraction)
+
+    @Suppress("IllegalExperimentalApiUsage") // TODO(b/233188423): Address before moving to beta
+    @ExperimentalFoundationApi
+    override fun Modifier.animateItemPlacement(animationSpec: FiniteAnimationSpec<IntOffset>) =
+        this.then(AnimateItemPlacementModifier(animationSpec, debugInspectorInfo {
+            name = "animateItemPlacement"
+            value = animationSpec
+        }))
+}
+
+private class AnimateItemPlacementModifier(
+    val animationSpec: FiniteAnimationSpec<IntOffset>,
+    inspectorInfo: InspectorInfo.() -> Unit,
+) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {
+    override fun Density.modifyParentData(parentData: Any?): Any = animationSpec
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is AnimateItemPlacementModifier) return false
+        return animationSpec != other.animationSpec
+    }
+
+    override fun hashCode(): Int {
+        return animationSpec.hashCode()
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListLayoutInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListLayoutInfo.kt
new file mode 100644
index 0000000..cc31459
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListLayoutInfo.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * Contains useful information about the currently displayed layout state of lazy lists like
+ * [TvLazyColumn] or [TvLazyRow]. For example you can get the list of currently displayed item.
+ *
+ * Use [TvLazyListState.layoutInfo] to retrieve this
+ */
+sealed interface TvLazyListLayoutInfo {
+    /**
+     * The list of [TvLazyListItemInfo] representing all the currently visible items.
+     */
+    val visibleItemsInfo: List<TvLazyListItemInfo>
+
+    /**
+     * The start offset of the layout's viewport in pixels. You can think of it as a minimum offset
+     * which would be visible. Usually it is 0, but it can be negative if non-zero [beforeContentPadding]
+     * was applied as the content displayed in the content padding area is still visible.
+     *
+     * You can use it to understand what items from [visibleItemsInfo] are fully visible.
+     */
+    val viewportStartOffset: Int
+
+    /**
+     * The end offset of the layout's viewport in pixels. You can think of it as a maximum offset
+     * which would be visible. It is the size of the lazy list layout minus [beforeContentPadding].
+     *
+     * You can use it to understand what items from [visibleItemsInfo] are fully visible.
+     */
+    val viewportEndOffset: Int
+
+    /**
+     * The total count of items passed to [TvLazyColumn] or [TvLazyRow].
+     */
+    val totalItemsCount: Int
+
+    /**
+     * The size of the viewport in pixels. It is the lazy list layout size including all the
+     * content paddings.
+     */
+    val viewportSize: IntSize
+
+    /**
+     * The orientation of the lazy list.
+     */
+    val orientation: Orientation
+
+    /**
+     * True if the direction of scrolling and layout is reversed.
+     */
+    val reverseLayout: Boolean
+
+    /**
+     * The content padding in pixels applied before the first item in the direction of scrolling.
+     * For example it is a top content padding for LazyColumn with reverseLayout set to false.
+     */
+    val beforeContentPadding: Int
+
+    /**
+     * The content padding in pixels applied after the last item in the direction of scrolling.
+     * For example it is a bottom content padding for LazyColumn with reverseLayout set to false.
+     */
+    val afterContentPadding: Int
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeImpl.kt
new file mode 100644
index 0000000..e91c249
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeImpl.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2021 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 androidx.tv.foundation.lazy.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.IntervalList
+import androidx.compose.foundation.lazy.layout.MutableIntervalList
+import androidx.compose.runtime.Composable
+
+@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
+@OptIn(ExperimentalFoundationApi::class)
+internal class TvLazyListScopeImpl : TvLazyListScope {
+
+    private val _intervals = MutableIntervalList<LazyListIntervalContent>()
+    val intervals: IntervalList<LazyListIntervalContent> = _intervals
+
+    private var _headerIndexes: MutableList<Int>? = null
+    val headerIndexes: List<Int> get() = _headerIndexes ?: emptyList()
+
+    override fun items(
+        count: Int,
+        key: ((index: Int) -> Any)?,
+        contentType: (index: Int) -> Any?,
+        itemContent: @Composable TvLazyListItemScope.(index: Int) -> Unit
+    ) {
+        _intervals.addInterval(
+            count,
+            LazyListIntervalContent(
+                key = key,
+                type = contentType,
+                item = itemContent
+            )
+        )
+    }
+
+    override fun item(
+        key: Any?,
+        contentType: Any?,
+        content: @Composable TvLazyListItemScope.() -> Unit
+    ) {
+        _intervals.addInterval(
+            1,
+            LazyListIntervalContent(
+                key = if (key != null) { _: Int -> key } else null,
+                type = { contentType },
+                item = { content() }
+            )
+        )
+    }
+}
+
+internal class LazyListIntervalContent(
+    val key: ((index: Int) -> Any)?,
+    val type: ((index: Int) -> Any?),
+    val item: @Composable TvLazyListItemScope.(index: Int) -> Unit
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeMarker.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeMarker.kt
new file mode 100644
index 0000000..4931977
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeMarker.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 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 androidx.tv.foundation.lazy.list
+
+/**
+ * DSL marker used to distinguish between lazy layout scope and the item scope.
+ */
+@DslMarker
+annotation class TvLazyListScopeMarker
\ No newline at end of file