diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/node/assertion/ListNodeAssertions.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/node/assertion/ListNodeAssertions.kt new file mode 100644 index 00000000..f1c1d5f1 --- /dev/null +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/node/assertion/ListNodeAssertions.kt @@ -0,0 +1,40 @@ +package io.github.kakaocup.compose.node.assertion +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import io.github.kakaocup.compose.node.assertion.NodeAssertions + +/** + * Basic assertions for working with lists. + */ +interface ListNodeAssertions : NodeAssertions { + + /** + * Semantic property for the list length. + */ + val lengthSemanticsPropertyKey: SemanticsPropertyKey + + /** + * Checks that the length of the list is equal to [length]. + * + * @throws [AssertionError] if the list length is not equal to [length]. + * @throws [IllegalStateException] if the list does not provide the [lengthSemanticsPropertyKey] property. + */ + fun assertLengthEquals(length: Int) { + delegate.check(NodeAssertions.ComposeBaseAssertionType.ASSERT_VALUE_EQUALS) { + assert(hasListLength(length)) + } + } + + private fun hasListLength(length: Int): SemanticsMatcher { + return SemanticsMatcher( + "The length of the list is expected to be $length, but the actual size is different" + ) { node -> + val actualLength = node.config.getOrNull(lengthSemanticsPropertyKey) + ?: error("List does not contain $lengthSemanticsPropertyKey modifier") + + actualLength == length + } + } +} \ No newline at end of file diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/node/core/BaseNode.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/node/core/BaseNode.kt index 68e37984..0aed2aae 100644 --- a/compose/src/main/kotlin/io/github/kakaocup/compose/node/core/BaseNode.kt +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/node/core/BaseNode.kt @@ -3,7 +3,6 @@ package io.github.kakaocup.compose.node.core import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.hasAnyAncestor -import io.github.kakaocup.compose.KakaoCompose import io.github.kakaocup.compose.intercept.delegate.ComposeDelegate import io.github.kakaocup.compose.intercept.delegate.ComposeInterceptable import io.github.kakaocup.compose.node.action.NodeActions @@ -18,9 +17,9 @@ import io.github.kakaocup.compose.utilities.orGlobal @ComposeMarker abstract class BaseNode> constructor( - @PublishedApi internal val semanticsProvider: SemanticsNodeInteractionsProvider? = null, - private val nodeMatcher: NodeMatcher, - private val parentNode: BaseNode<*>? = null, + @PublishedApi internal var semanticsProvider: SemanticsNodeInteractionsProvider? = null, + private var nodeMatcher: NodeMatcher? = null, + private var parentNode: BaseNode<*>? = null, ) : KDSL, NodeAssertions, TextResourcesNodeAssertions, @@ -28,7 +27,6 @@ abstract class BaseNode> constructor( TextActions, ComposeInterceptable { - constructor( semanticsProvider: SemanticsNodeInteractionsProvider? = null, viewBuilderAction: ViewBuilder.() -> Unit, @@ -52,8 +50,8 @@ abstract class BaseNode> constructor( nodeProvider = NodeProvider( nodeMatcher = NodeMatcher( matcher = combineSemanticMatchers(), - position = nodeMatcher.position, - useUnmergedTree = nodeMatcher.useUnmergedTree + position = nodeMatcher.checkNotNull().position, + useUnmergedTree = nodeMatcher.checkNotNull().useUnmergedTree ), semanticsProvider = semanticsProvider.orGlobal().checkNotNull() ), @@ -67,12 +65,26 @@ abstract class BaseNode> constructor( NodeMatcher::class.java, BaseNode::class.java, ).newInstance( - semanticsProvider, + semanticsProvider.orGlobal().checkNotNull(), ViewBuilder().apply(function).build(), this, ) } + /** + * Method for deferred initialization of [BaseNode] constructor parameters. + * Simplifies the description of child nodes in list nodes. + */ + fun initSemantics( + semanticsProvider: SemanticsNodeInteractionsProvider, + nodeMatcher: NodeMatcher, + parentNode: BaseNode<*>? = null, + ) { + this.semanticsProvider = semanticsProvider + this.nodeMatcher = nodeMatcher + this.parentNode = parentNode + } + /*** * Combines semantic matchers from all ancestor nodes */ @@ -81,10 +93,10 @@ abstract class BaseNode> constructor( var parent = this.parentNode while (parent != null) { - semanticsMatcherList.add(hasAnyAncestor(parent.nodeMatcher.matcher)) + semanticsMatcherList.add(hasAnyAncestor(parent.nodeMatcher.checkNotNull().matcher)) parent = parent.parentNode } - semanticsMatcherList.add(this.nodeMatcher.matcher) + semanticsMatcherList.add(this.nodeMatcher.checkNotNull().matcher) return semanticsMatcherList.reduce { finalMatcher, matcher -> finalMatcher and matcher } } diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/ComposeScreen.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/ComposeScreen.kt index cea2d4a2..c9f7136f 100644 --- a/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/ComposeScreen.kt +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/ComposeScreen.kt @@ -2,7 +2,6 @@ package io.github.kakaocup.compose.node.element import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import io.github.kakaocup.compose.KakaoCompose import io.github.kakaocup.compose.node.core.ComposeMarker import io.github.kakaocup.compose.node.core.BaseNode import io.github.kakaocup.compose.node.builder.NodeMatcher @@ -43,7 +42,7 @@ open class ComposeScreen> : BaseNode { companion object { inline fun > onComposeScreen( semanticsProvider: SemanticsNodeInteractionsProvider, - noinline function: T.() -> Unit + noinline function: T.() -> Unit, ): T = T::class.java .getDeclaredConstructor( SemanticsNodeInteractionsProvider::class.java @@ -52,7 +51,7 @@ open class ComposeScreen> : BaseNode { .apply { this(function) } inline fun > onComposeScreen( - noinline function: T.() -> Unit + noinline function: T.() -> Unit, ): T = T::class.java.getDeclaredConstructor() .newInstance() diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/lazylist/KLazyListNode.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/lazylist/KLazyListNode.kt index 573f4b33..abc50d2b 100644 --- a/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/lazylist/KLazyListNode.kt +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/lazylist/KLazyListNode.kt @@ -7,8 +7,6 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.filter import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.onChildren -import io.github.kakaocup.compose.KakaoCompose -import io.github.kakaocup.compose.exception.KakaoComposeException import io.github.kakaocup.compose.node.assertion.LazyListNodeAssertions import io.github.kakaocup.compose.node.builder.NodeMatcher import io.github.kakaocup.compose.node.builder.ViewBuilder @@ -81,9 +79,7 @@ class KLazyListNode( function(provideItem( semanticsNode, - semanticsProvider - .orGlobal() - .checkNotNull() + semanticsProvider.orGlobal().checkNotNull() ) as T) } @@ -114,9 +110,7 @@ class KLazyListNode( return provideItem( semanticsNode, - semanticsProvider - .orGlobal() - .checkNotNull() + semanticsProvider.orGlobal().checkNotNull() ) as T } diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/list/KListItemNode.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/list/KListItemNode.kt new file mode 100644 index 00000000..5d68902e --- /dev/null +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/list/KListItemNode.kt @@ -0,0 +1,54 @@ +package io.github.kakaocup.compose.node.element.list + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.SemanticsMatcher +import io.github.kakaocup.compose.node.builder.NodeMatcher +import io.github.kakaocup.compose.node.core.BaseNode +import io.github.kakaocup.compose.utilities.checkNotNull +import io.github.kakaocup.compose.utilities.orGlobal + +/** + * Base class for all child nodes within [KListNode]. + * + * The constructor is declared as `protected` so that only inheritors have the right to call this constructor. + * + * Warning! Manually creating list items is **not necessary**. + */ +open class KListItemNode> protected constructor() : BaseNode() { + + companion object { + + /** + * Method for correctly initializing the necessary parameters of [BaseNode]. + * This method allows us to keep the main constructor of the element empty, which greatly + * simplifies the description of subclass elements. + * + * @param listNode The root node of the list within which we need to interact with the list item. + * @param semanticsNode A list of key/value pairs associated with the layout node or its subtree. + * @param useUnmergedTree If true, the unmerged semantic tree will be used to work with the node. + */ + inline fun > newInstance( + listNode: KListNode, + semanticsNode: SemanticsNode, + useUnmergedTree: Boolean, + ): T { + val instance = T::class.java.getDeclaredConstructor().newInstance() + + instance.initSemantics( + semanticsProvider = listNode.semanticsProvider.orGlobal().checkNotNull(), + nodeMatcher = NodeMatcher( + matcher = SemanticsMatcher( + description = "Semantics node id = ${semanticsNode.id}", + matcher = { it.id == semanticsNode.id }, + ), + useUnmergedTree = useUnmergedTree, + ), + parentNode = listNode, + ) + + return instance + } + + } + +} diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/list/KListNode.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/list/KListNode.kt new file mode 100644 index 00000000..64dc6279 --- /dev/null +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/node/element/list/KListNode.kt @@ -0,0 +1,553 @@ +package io.github.kakaocup.compose.node.element.list + +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.onChildren +import io.github.kakaocup.compose.KakaoCompose +import io.github.kakaocup.compose.node.action.NodeActions.ComposeBaseActionType +import io.github.kakaocup.compose.node.assertion.ListNodeAssertions +import io.github.kakaocup.compose.node.builder.NodeMatcher +import io.github.kakaocup.compose.node.builder.ViewBuilder +import io.github.kakaocup.compose.node.core.BaseNode +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.utilities.checkNotNull +import io.github.kakaocup.compose.utilities.orGlobal + +/** + * A slightly modified copy of [io.github.kakaocup.compose.node.element.lazylist.KLazyListNode]. + * + * [KListNode] is intended for testing any lists. + * + * It can be used with both lazy and non-lazy containers containing any elements. + * + * - [androidx.compose.foundation.lazy.LazyColumn]. + * - [androidx.compose.foundation.lazy.LazyRow]. + * - [androidx.compose.foundation.layout.Row] + Modifier.[androidx.compose.foundation.horizontalScroll] (or without it). + * - [androidx.compose.foundation.layout.Column] + Modifier.[androidx.compose.foundation.verticalScroll] (or without it). + * + * @param semanticsProvider ComposeRule for finding and interacting with semantic tree nodes. + * @param nodeMatcher Node matcher. + * @param parentNode Parent node (if any). + * @param useUnmergedTree If true, use the unmerged semantic tree; otherwise, use the merged tree. + * @param isScrollable If true, the [KListNode] is considered scrollable and allows scrolling by default + * when searching for individual items. If false, no scrolling will occur. By default, it is assumed that the list can + * scroll itself. + * @param itemIndexSemanticsPropertyKey Semantic property key for the list item index. + * @param lengthSemanticsPropertyKey Semantic property key for the list length. + */ +class KListNode( + semanticsProvider: SemanticsNodeInteractionsProvider, + nodeMatcher: NodeMatcher, + parentNode: BaseNode<*>?, + val useUnmergedTree: Boolean = KakaoCompose.Override.useUnmergedTree ?: false, + val isScrollable: Boolean = true, + val itemIndexSemanticsPropertyKey: SemanticsPropertyKey?, + override val lengthSemanticsPropertyKey: SemanticsPropertyKey, +) : BaseNode(semanticsProvider, nodeMatcher, parentNode), + ListNodeAssertions { + + companion object { + val itemIndexPropertyErrorMessage = """ + |[itemIndexSemanticsPropertyKey] parameter is not specified for KListNode + |Configure it by using [KListNode(.., itemIndexSemanticsPropertyKey = ListItemIndexPropertyKey)]""" + .trimMargin() + } + + val rootNodeMatcher = nodeMatcher.matcher + + /** + * Constructor for creating [KListNode] using a convenient [ViewBuilder]. + * + * @param semanticsProvider ComposeRule for finding and interacting with semantic tree nodes. + * @param viewBuilderAction Lambda for building the node matcher using [ViewBuilder]. + * @param parentNode Parent node (if any). + * @param useUnmergedTree If true, use the unmerged semantic tree; otherwise, use the merged tree. + * @param isScrollable If true, the [KListNode] is considered scrollable and allows scrolling by default + * when searching for individual items. If false, no scrolling will occur. By default, it is assumed that the list can + * scroll itself. + * @param itemIndexSemanticsPropertyKey Semantic property key for the list item index. + * @param lengthSemanticsPropertyKey Semantic property key for the list length. + */ + constructor( + semanticsProvider: SemanticsNodeInteractionsProvider, + viewBuilderAction: ViewBuilder.() -> Unit, + parentNode: BaseNode<*>?, + useUnmergedTree: Boolean, + isScrollable: Boolean, + itemIndexSemanticsPropertyKey: SemanticsPropertyKey?, + lengthSemanticsPropertyKey: SemanticsPropertyKey, + ) : this( + semanticsProvider = semanticsProvider, + nodeMatcher = ViewBuilder().apply(viewBuilderAction).build(), + parentNode = parentNode, + useUnmergedTree = useUnmergedTree, + isScrollable = isScrollable, + itemIndexSemanticsPropertyKey = itemIndexSemanticsPropertyKey, + lengthSemanticsPropertyKey = lengthSemanticsPropertyKey, + ) + + // region Overriding base node actions + + /** + * Performs scroll to position with [index]. + * + * We have to override this action from [io.github.kakaocup.compose.node.action.NodeActions], because not every + * scrollable container has [androidx.compose.ui.semantics.SemanticsActions.ScrollToIndex] action. + * Using [androidx.compose.ui.semantics.SemanticsActions.ScrollBy] action is a more reliable solution + * with child Matcher on item index. + * + * @throws NullPointerException if [itemIndexSemanticsPropertyKey] is `null`. + * @throws [IllegalStateException] if this node marked as not scrollable (see [isScrollable]). + * @throws [AssertionError] if node doesn't have [androidx.compose.ui.semantics.SemanticsActions.ScrollBy] action. + */ + @OptIn(ExperimentalTestApi::class) + override fun performScrollToIndex(index: Int) { + check(isScrollable) + getItemAt(index) + } + + // endregion + + // region Methods for checking the existence / non-existence of items in the list + + /** + * Checks that the item at the specified position is displayed. + * + * @param index Position of the item. + * + * @throws NullPointerException if [itemIndexSemanticsPropertyKey] is `null`. + * @throws [AssertionError] if the node cannot be found in the list. + */ + @OptIn(ExperimentalTestApi::class) + fun assertItemIsDisplayedAt(index: Int) { + val childMatcher = buildChildMatcher(index) + + if (isScrollable) { + delegate.perform(ComposeBaseActionType.PERFORM_SCROLL_TO_NODE) { + performScrollToNode(childMatcher.matcher) + } + } else { + semanticsProvider + .orGlobal() + .checkNotNull() + .onNode(rootNodeMatcher, this.useUnmergedTree) + .onChildren() + .filter(childMatcher.matcher)[childMatcher.position] + .assertIsDisplayed() + } + } + + /** + * Checks that the item at the specified position is not displayed. + * + * @param index Position of the item. + * + * @throws NullPointerException if [itemIndexSemanticsPropertyKey] is `null`. + * @throws [AssertionError] if the node can be found in the list. + */ + @OptIn(ExperimentalTestApi::class) + fun assertItemIsNotDisplayedAt(index: Int) { + val childMatcher = buildChildMatcher(index) + + if (isScrollable) { + delegate.perform(ComposeBaseActionType.PERFORM_SCROLL_TO_NODE) { + try { + performScrollToNode(childMatcher.matcher) + error("Found node that matches ${childMatcher.matcher.description}") + } catch (ex: IllegalStateException) { + // If we are at this point, it means we found a node that should not exist. + throw ex + } catch (ignored: Throwable) { + // If we are at this point -> everything is fine. + } + } + } else { + semanticsProvider + .orGlobal() + .checkNotNull() + .onNode(rootNodeMatcher, this.useUnmergedTree) + .onChildren() + .filter(childMatcher.matcher)[childMatcher.position] + .assertIsNotDisplayed() + } + } + + /** + * Checks that the item found using the Matcher exists in the list. + * + * @param viewBuilderAction Builder for creating the search matcher. + * + * @throws [AssertionError] if the node cannot be found in the list. + */ + @OptIn(ExperimentalTestApi::class) + fun assertItemWithIsDisplayed(viewBuilderAction: ViewBuilder.() -> Unit) { + val childMatcher = buildChildMatcher(viewBuilderAction) + + if (isScrollable) { + delegate.perform(ComposeBaseActionType.PERFORM_SCROLL_TO_NODE) { + performScrollToNode(childMatcher.matcher) + } + } else { + val childNode = this.child(viewBuilderAction) + childNode.assertIsDisplayed() + } + } + + /** + * Checks that the item found using the Matcher does not exist in the list. + * + * @param viewBuilderAction Builder for creating the search matcher. + * + * @throws [AssertionError] if the node can be found in the list. + */ + @OptIn(ExperimentalTestApi::class) + fun assertItemWithIsNotDisplayed(viewBuilderAction: ViewBuilder.() -> Unit) { + val childMatcher = buildChildMatcher(viewBuilderAction) + + if (isScrollable) { + delegate.perform(ComposeBaseActionType.PERFORM_SCROLL_TO_NODE) { + try { + performScrollToNode(childMatcher.matcher) + error("Found node that matches ${childMatcher.matcher.description}") + } catch (ex: IllegalStateException) { + // If we are at this point, it means we found a node that should not exist. + throw ex + } catch (ignored: Throwable) { + // If we are at this point -> everything is fine. + } + } + } else { + val childNode = this.child(viewBuilderAction) + childNode.assertIsNotDisplayed() + } + } + + // endregion + + // region Methods for working with items at a position + + /** + * Performs the specified actions / checks on the list item at the specified position. + * + * This method should be used if you DO NOT NEED a specific type of list item node. + * + * @param index Index of the item in the list node. + * @param needPerformScroll If true, additional scrolling will be performed to the specified position. + * By default, scrolling depends on the [isScrollable] flag. + * @param function Function for processing the item. + * + * @throws NullPointerException if [itemIndexSemanticsPropertyKey] is `null`. + */ + fun itemAt( + index: Int, + needPerformScroll: Boolean = isScrollable, + function: KListItemNode<*>.() -> Unit, + ) { + childAt>(index, needPerformScroll, function) + } + + /** + * Performs the specified actions / checks on the list item at the specified position. + * + * This method should be used if you NEED a specific type of list item node. + * + * @param T Specific type of list item. + * @param index Index of the item in the list node. + * @param needPerformScroll If true, additional scrolling will be performed to the specified position. + * By default, scrolling depends on the [isScrollable] flag. + * @param function Function for processing the item. + * + * @throws NullPointerException if [itemIndexSemanticsPropertyKey] is `null`. + */ + inline fun > childAt( + index: Int, + needPerformScroll: Boolean = isScrollable, + function: T.() -> Unit, + ) { + val childItem = getChildAt(index, needPerformScroll = needPerformScroll) + function.invoke(childItem) + } + + /** + * Performs the specified actions / checks on the list item at the first position. + * + * This method should be used if you DO NOT NEED a specific type of list item node. + * + * @param needPerformScroll If true, additional scrolling will be performed to the first position. + * By default, scrolling depends on the [isScrollable] flag. + * @param function Function for processing the item. + */ + fun firstItem( + needPerformScroll: Boolean = isScrollable, + function: KListItemNode<*>.() -> Unit, + ) { + firstChild(needPerformScroll, function) + } + + /** + * Performs the specified actions / checks on the list item at the first position. + * + * This method should be used if you NEED a specific type of list item node. + * + * @param T Specific type of list item. + * @param needPerformScroll If true, additional scrolling will be performed to the first position. + * By default, scrolling depends on the [isScrollable] flag. + * @param function Function for processing the item. + */ + inline fun > firstChild( + needPerformScroll: Boolean = isScrollable, + function: T.() -> Unit, + ) { + childAt(0, needPerformScroll, function) + } + + // endregion + + // region Methods for working with items by Matcher + + /** + * Finds a child element of the list node using [ViewBuilder] and performs [function]. + * + * This method should be used if you DO NOT NEED a specific type of list item node. + * + * @param viewBuilderAction Builder for creating the search matcher. + * @param needPerformScroll If true, additional scrolling will be performed to the element with + * the built [SemanticsMatcher]. By default, scrolling depends on the [isScrollable] flag. + * @param function Function for processing the item. + */ + fun itemWith( + viewBuilderAction: ViewBuilder.() -> Unit, + needPerformScroll: Boolean = isScrollable, + function: KListItemNode<*>.() -> Unit, + ) { + val item = getItemWith(viewBuilderAction, needPerformScroll) + function.invoke(item) + } + + /** + * Finds a child element of the list node using [ViewBuilder] and performs [function]. + * + * This method should be used if you NEED a specific type of list item node. + * + * @param T Specific type of list item. + * @param viewBuilderAction Builder for creating the search matcher. + * @param needPerformScroll If true, additional scrolling will be performed to the element with + * the built [SemanticsMatcher]. By default, scrolling depends on the [isScrollable] flag. + * @param function Function for processing the item. + */ + inline fun > childWith( + noinline viewBuilderAction: ViewBuilder.() -> Unit, + needPerformScroll: Boolean = isScrollable, + function: T.() -> Unit, + ) { + val item = getChildWith(viewBuilderAction, needPerformScroll) + function.invoke(item) + } + + // endregion + + // region Getters for list items + + /** + * Returns a child element of the list node found using [ViewBuilder]. + * + * This method should be used if you DO NOT NEED a specific type of list item node. + * + * @param viewBuilderAction Builder for creating the search matcher. + * @param needPerformScroll If true, additional scrolling will be performed to the element with + * the built [SemanticsMatcher]. By default, scrolling depends on the [isScrollable] flag. + */ + fun getItemWith( + viewBuilderAction: ViewBuilder.() -> Unit, + needPerformScroll: Boolean = isScrollable, + ): KListItemNode<*> = + getChildWith(viewBuilderAction, needPerformScroll) + + /** + * Returns a child element of the list node found using [ViewBuilder]. + * + * This method should be used if you NEED a specific type of list item node. + * + * @param T Specific type of list item. + * @param viewBuilderAction Builder for creating the search matcher. + * @param needPerformScroll If true, additional scrolling will be performed to the element with + * the built [SemanticsMatcher]. By default, scrolling depends on the [isScrollable] flag. + */ + @OptIn(ExperimentalTestApi::class) + inline fun > getChildWith( + noinline viewBuilderAction: ViewBuilder.() -> Unit, + needPerformScroll: Boolean = isScrollable, + ): T { + val childMatcher = buildChildMatcher(viewBuilderAction) + + if (needPerformScroll) { + performScrollToNode(childMatcher.matcher) + } + + val semanticsNode = semanticsProvider + .orGlobal() + .checkNotNull() + .onNode(rootNodeMatcher, this.useUnmergedTree) + .onChildren() + .filter(childMatcher.matcher)[childMatcher.position] + .fetchSemanticsNode() + + return createListItemNode(semanticsNode) + } + + /** + * Returns a child element of the list node at the specified position. + * Depending on the [needPerformScroll] parameter, additional scrolling may be performed to the element at + * the specified position. + * + * This method should be used if you DO NOT NEED a specific type of list item node. + * + * @param index Index of the item in the list node. + * @param needPerformScroll If true, we will try to scroll to the specified position. + * + * @throws NullPointerException if [needPerformScroll] is true and [itemIndexSemanticsPropertyKey] is `null`. + */ + fun getItemAt(index: Int, needPerformScroll: Boolean = isScrollable): KListItemNode<*> = + getChildAt(index, needPerformScroll) + + /** + * Returns a child element of the list node at the specified position. + * Depending on the [needPerformScroll] parameter, additional scrolling may be performed to the element at + * the specified position. + * + * This method should be used if you NEED a specific type of list item node. + * + * @param T Specific type of element. + * @param index Index of the item in the list node. + * @param needPerformScroll If true, we will try to scroll to the specified position. + * + * @throws NullPointerException if [needPerformScroll] is true and [itemIndexSemanticsPropertyKey] is `null`. + */ + @OptIn(ExperimentalTestApi::class) + inline fun > getChildAt(index: Int, needPerformScroll: Boolean = isScrollable): T { + val childMatcher = buildChildMatcher(index) + + if (needPerformScroll) { + performScrollToNode(childMatcher.matcher) + } + + // Warning! + // Within lazy collections, `filterToOne` cannot be used on child nodes. + // In Compose version 1.5.5, the semantic tree of lazy collections MAY CONTAIN DUPLICATES. + val semanticsNode = semanticsProvider + .orGlobal() + .checkNotNull() + .onNode(rootNodeMatcher, this.useUnmergedTree) + .onChildren() + .filter(childMatcher.matcher)[childMatcher.position] + .fetchSemanticsNode() + + return createListItemNode(semanticsNode) + } + + // endregion + + inline fun > createListItemNode(semanticsNode: SemanticsNode): T { + return KListItemNode.newInstance( + listNode = this, + semanticsNode = semanticsNode, + useUnmergedTree = useUnmergedTree, + ) + } + + fun buildChildMatcher(viewBuilderAction: ViewBuilder.() -> Unit): NodeMatcher { + return ViewBuilder() + .apply { + this.useUnmergedTree = this@KListNode.useUnmergedTree + } + .apply(viewBuilderAction) + .build() + } + + fun buildChildMatcher(index: Int): NodeMatcher { + requireNotNull(itemIndexSemanticsPropertyKey) { itemIndexPropertyErrorMessage } + + return ViewBuilder() + .apply { + this.useUnmergedTree = this@KListNode.useUnmergedTree + addSemanticsMatcher( + SemanticsMatcher.expectValue(this@KListNode.itemIndexSemanticsPropertyKey, index) + ) + } + .build() + } + +} + +// region Additional factory methods + +/** + * Simplified builder for creating [KListNode]. + * + * @param useUnmergedTree If true, the unmerged semantic tree is used; otherwise, the merged tree is used. + * @param isScrollable If true, the [KListNode] is considered scrollable and allows scrolling by default + * when searching for individual items. If false, no scrolling will occur. By default, it is assumed that the list can + * scroll itself. + * @param itemIndexSemanticsPropertyKey Semantic property key for the list item index. + * @param lengthSemanticsPropertyKey Semantic property key for the list length. + * @param viewBuilderAction Lambda for building the node matcher using [ViewBuilder]. + */ +fun BaseNode<*>.KListNode( + useUnmergedTree: Boolean = KakaoCompose.Override.useUnmergedTree ?: false, + isScrollable: Boolean = true, + itemIndexSemanticsPropertyKey: SemanticsPropertyKey? = null, + lengthSemanticsPropertyKey: SemanticsPropertyKey, + viewBuilderAction: ViewBuilder.() -> Unit, +): KListNode { + val nodeMatcher = ViewBuilder() + .apply { + this.useUnmergedTree = useUnmergedTree + } + .apply(viewBuilderAction) + .build() + + return KListNode( + semanticsProvider = this.semanticsProvider.orGlobal().checkNotNull(), + nodeMatcher = nodeMatcher, + parentNode = this, + useUnmergedTree = useUnmergedTree, + isScrollable = isScrollable, + itemIndexSemanticsPropertyKey = itemIndexSemanticsPropertyKey, + lengthSemanticsPropertyKey = lengthSemanticsPropertyKey, + ) +} + +/** + * Simplified builder for creating [KListNode] with tesgTag. + * + * @param testTag Tag for searching the node. + * @param useUnmergedTree If true, the unmerged semantic tree is used; otherwise, the merged tree is used. + * @param isScrollable If true, the [KListNode] is considered scrollable and allows scrolling by default + * when searching for individual items. If false, no scrolling will occur. By default, it is assumed that the list can + * scroll itself. + * @param itemIndexSemanticsPropertyKey Semantic property key for the list item index. + * @param lengthSemanticsPropertyKey Semantic property key for the list length. + */ +fun BaseNode<*>.KListNode( + testTag: String, + useUnmergedTree: Boolean = KakaoCompose.Override.useUnmergedTree ?: false, + isScrollable: Boolean = true, + itemIndexSemanticsPropertyKey: SemanticsPropertyKey? = null, + lengthSemanticsPropertyKey: SemanticsPropertyKey, +): KListNode = KListNode( + useUnmergedTree = useUnmergedTree, + isScrollable = isScrollable, + itemIndexSemanticsPropertyKey = itemIndexSemanticsPropertyKey, + lengthSemanticsPropertyKey = lengthSemanticsPropertyKey, +) { + hasTestTag(testTag) + this.useUnmergedTree = useUnmergedTree +} + +// endregion diff --git a/compose/src/main/kotlin/io/github/kakaocup/compose/utilities/Extensions.kt b/compose/src/main/kotlin/io/github/kakaocup/compose/utilities/Extensions.kt index 614ca258..938c947b 100644 --- a/compose/src/main/kotlin/io/github/kakaocup/compose/utilities/Extensions.kt +++ b/compose/src/main/kotlin/io/github/kakaocup/compose/utilities/Extensions.kt @@ -3,9 +3,13 @@ package io.github.kakaocup.compose.utilities import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import io.github.kakaocup.compose.KakaoCompose import io.github.kakaocup.compose.exception.KakaoComposeException +import io.github.kakaocup.compose.node.builder.NodeMatcher fun SemanticsNodeInteractionsProvider?.orGlobal(): SemanticsNodeInteractionsProvider? = this ?: KakaoCompose.Global.semanticsProvider fun SemanticsNodeInteractionsProvider?.checkNotNull(): SemanticsNodeInteractionsProvider = - this ?: throw KakaoComposeException("SemanticsProvider is null: Provide via constructor or use KakaoComposeTestRule") \ No newline at end of file + this ?: throw KakaoComposeException("SemanticsProvider is null: Provide via constructor or use KakaoComposeTestRule") + +fun NodeMatcher?.checkNotNull(): NodeMatcher = + this ?: throw KakaoComposeException("NodeMatcher is null: Provide via constructor or use 'initSemantics' method") \ No newline at end of file diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 94c63ac0..7b46a77a 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -36,7 +36,12 @@ android { kotlinCompilerExtensionVersion = libs.versions.composeCompilerVersion.get() } - packaging.resources.excludes.add("META-INF/*") + packaging { + resources { + pickFirsts += "META-INF/androidx.compose.*.version" + excludes += "META-INF/*" + } + } kotlin { jvmToolchain(17) diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/SampleNoActivityTest.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/SampleNoActivityTest.kt new file mode 100644 index 00000000..6f8574ed --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/SampleNoActivityTest.kt @@ -0,0 +1,19 @@ +package io.github.kakaocup.compose + +import androidx.compose.ui.test.junit4.createComposeRule +import io.github.kakaocup.compose.rule.KakaoComposeTestRule +import org.junit.Rule + +abstract class SampleNoActivityTest { + + init { + KakaoCompose.Override.useUnmergedTree = true + } + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val kakaoComposeTestRule = KakaoComposeTestRule(composeTestRule) + +} diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/node/KAppListNode.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/node/KAppListNode.kt new file mode 100644 index 00000000..bfed9d83 --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/node/KAppListNode.kt @@ -0,0 +1,18 @@ +package io.github.kakaocup.compose.node + +import io.github.kakaocup.compose.node.core.BaseNode +import io.github.kakaocup.compose.node.element.list.KListNode +import io.github.kakaocup.compose.sample.semantics.ListItemIndexSemanticKey +import io.github.kakaocup.compose.sample.semantics.ListLengthSemanticKey + +fun BaseNode<*>.KAppListNode( + testTag: String, + isScrollable: Boolean = true, +) = KListNode( + useUnmergedTree = true, + itemIndexSemanticsPropertyKey = ListItemIndexSemanticKey, + lengthSemanticsPropertyKey = ListLengthSemanticKey, + isScrollable = isScrollable, +) { + hasTestTag(testTag) +} diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/NotScrollableListComposeScreen.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/NotScrollableListComposeScreen.kt new file mode 100644 index 00000000..9449ee27 --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/NotScrollableListComposeScreen.kt @@ -0,0 +1,31 @@ +package io.github.kakaocup.compose.screen.list + +import io.github.kakaocup.compose.node.KAppListNode +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.list.KListItemNode + +class NotScrollableListComposeScreen : ComposeScreen() { + + val notScrollableListNode = KAppListNode( + testTag = "tabbar_container", + isScrollable = false, + ) + + class TabBarListItemNode : KListItemNode() { + + val text: KNode by lazy { + child { + hasTestTag("tabbar_item_text") + } + } + + val icon: KNode by lazy { + child { + hasTestTag("tabbar_item_icon") + } + } + + } + +} \ No newline at end of file diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/ScrollableLazyListComposeScreen.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/ScrollableLazyListComposeScreen.kt new file mode 100644 index 00000000..c0abfb05 --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/ScrollableLazyListComposeScreen.kt @@ -0,0 +1,23 @@ +package io.github.kakaocup.compose.screen.list + +import io.github.kakaocup.compose.node.KAppListNode +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.list.KListItemNode + +class ScrollableLazyListComposeScreen : ComposeScreen() { + + val lazyList = KAppListNode("LazyList") + + val pullToRefresh = KNode() { hasTestTag("PullToRefresh") } + + class ListHeaderNode : KListItemNode() { + + val title: KNode by lazy { + child { + hasTestTag("LazyListHeaderTitle") + } + } + } + +} diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/ScrollableListComposeScreen.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/ScrollableListComposeScreen.kt new file mode 100644 index 00000000..e3b62067 --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/screen/list/ScrollableListComposeScreen.kt @@ -0,0 +1,23 @@ +package io.github.kakaocup.compose.screen.list + +import io.github.kakaocup.compose.node.KAppListNode +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.list.KListItemNode + +class ScrollableListComposeScreen : ComposeScreen() { + + val scrollableList = KAppListNode("ScrollableColumn") + + val pullToRefresh = KNode() { hasTestTag("PullToRefresh") } + + class ListHeaderNode : KListItemNode() { + + val title: KNode by lazy { + child { + hasTestTag("ListHeaderTitle") + } + } + } + +} diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/NotScrollableListTest.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/NotScrollableListTest.kt new file mode 100644 index 00000000..4571dd95 --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/NotScrollableListTest.kt @@ -0,0 +1,63 @@ +package io.github.kakaocup.compose.test.list + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Email +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import io.github.kakaocup.compose.KakaoCompose +import io.github.kakaocup.compose.SampleNoActivityTest +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.rule.KakaoComposeTestRule +import io.github.kakaocup.compose.sample.NotScrollableListScreen +import io.github.kakaocup.compose.sample.semantics.ImageContentSemanticKey +import io.github.kakaocup.compose.screen.list.NotScrollableListComposeScreen +import org.junit.Rule +import org.junit.Test + +class NotScrollableListTest : SampleNoActivityTest() { + + @Test + fun test() { + composeTestRule.setContent { + MaterialTheme { + NotScrollableListScreen() + } + } + + onComposeScreen() { + notScrollableListNode { + assertIsDisplayed() + + assertLengthEquals(5) + + itemAt(1) { + performClick() + assertIsSelected() + } + + childAt(2) { + text { + assertTextEquals("Responses") + } + icon { + assert( + SemanticsMatcher.expectValue(ImageContentSemanticKey, Icons.Outlined.Email) + ) + } + } + + itemWith({ + hasAnyDescendant( + androidx.compose.ui.test.hasText("Profile") + ) + }) { + assertIsNotSelected() + performClick() + assertIsSelected() + } + } + } + } + +} \ No newline at end of file diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/ScrollableLazyListTest.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/ScrollableLazyListTest.kt new file mode 100644 index 00000000..b78728cf --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/ScrollableLazyListTest.kt @@ -0,0 +1,68 @@ +package io.github.kakaocup.compose.test.list + +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.swipeDown +import io.github.kakaocup.compose.SampleNoActivityTest +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.sample.ScrollableLazyListScreen +import io.github.kakaocup.compose.screen.list.ScrollableLazyListComposeScreen +import org.junit.Test + +class ScrollableLazyListTest : SampleNoActivityTest() { + + @OptIn(ExperimentalTestApi::class) + @Test + fun test() { + composeTestRule.setContent { + MaterialTheme { + ScrollableLazyListScreen() + } + } + + onComposeScreen() { + lazyList.assertLengthEquals(0) + + pullToRefresh.performTouchInput { + swipeDown(startY = 200f) + } + + lazyList { + assertLengthEquals(33) + firstChild { + title.assertTextEquals("Items from 1 to 10") + } + + itemWith( + { hasText("Item 1") } + ) { + assertTextEquals("Item 1") + } + + itemAt(10) { + assertTextEquals("Item 10") + } + childWith( + { hasAnyDescendant(androidx.compose.ui.test.hasText("Items from 21 to 30")) } + ) { + title.assertTextEquals("Items from 21 to 30") + } + itemAt(23) { + assertTextEquals("Item 21") + } + itemAt(32) { + assertTextEquals("Item 30") + } + } + + lazyList.performScrollToIndex(0) + + pullToRefresh.performTouchInput { + swipeDown(startY = 200f) + } + lazyList.assertLengthEquals(34) + + } + } + +} \ No newline at end of file diff --git a/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/ScrollableListTest.kt b/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/ScrollableListTest.kt new file mode 100644 index 00000000..c8b591db --- /dev/null +++ b/sample/src/androidTest/java/io/github/kakaocup/compose/test/list/ScrollableListTest.kt @@ -0,0 +1,70 @@ +package io.github.kakaocup.compose.test.list + +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.swipeDown +import io.github.kakaocup.compose.SampleNoActivityTest +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.sample.ScrollableListScreen +import io.github.kakaocup.compose.screen.LazyListHeaderNode +import io.github.kakaocup.compose.screen.LazyListItemNode +import io.github.kakaocup.compose.screen.list.ScrollableListComposeScreen +import org.junit.Test + +class ScrollableListTest : SampleNoActivityTest() { + + @OptIn(ExperimentalTestApi::class) + @Test + fun test() { + composeTestRule.setContent { + MaterialTheme { + ScrollableListScreen() + } + } + + onComposeScreen() { + scrollableList.assertLengthEquals(0) + + pullToRefresh.performTouchInput { + swipeDown(startY = 200f) + } + + scrollableList { + assertLengthEquals(33) + firstChild { + title.assertTextEquals("Items from 1 to 10") + } + + itemWith( + { hasText("Item 1") } + ) { + assertTextEquals("Item 1") + } + + itemAt(10) { + assertTextEquals("Item 10") + } + childWith( + { hasAnyDescendant(androidx.compose.ui.test.hasText("Items from 21 to 30")) } + ) { + title.assertTextEquals("Items from 21 to 30") + } + itemAt(23) { + assertTextEquals("Item 21") + } + itemAt(32) { + assertTextEquals("Item 30") + } + } + + scrollableList.performScrollToIndex(0) + + pullToRefresh.performTouchInput { + swipeDown(startY = 200f) + } + scrollableList.assertLengthEquals(34) + + } + } + +} \ No newline at end of file diff --git a/sample/src/main/java/io/github/kakaocup/compose/sample/NotScrollableListScreen.kt b/sample/src/main/java/io/github/kakaocup/compose/sample/NotScrollableListScreen.kt new file mode 100644 index 00000000..7b4e06e4 --- /dev/null +++ b/sample/src/main/java/io/github/kakaocup/compose/sample/NotScrollableListScreen.kt @@ -0,0 +1,231 @@ +package io.github.kakaocup.compose.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Send +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.kakaocup.compose.sample.semantics.imageContentSemantic +import io.github.kakaocup.compose.sample.semantics.listItemIndexSemantic +import io.github.kakaocup.compose.sample.semantics.listLength + +/** + * Sample with not scrollable list. + */ +@Composable +fun NotScrollableListScreen() { + Box( + modifier = Modifier + .fillMaxSize() + ) { + var selectedTabId by remember { + mutableStateOf(TabBarItem.Search.id) + } + + CustomTabBar( + selectedTabId = selectedTabId, + onSelect = { selectedTabId = it }, + modifier = Modifier + .align(Alignment.BottomStart) + ) + } +} + +/** + * Example of not scrollable list. + */ +@Composable +private fun CustomTabBar( + selectedTabId: String, + onSelect: (id: String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 49.dp) + .background(Color.White) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.Gray) + ) + + // Warning! + // There is no `Modifier.horizontalScroll`, so there is no `AccessibilityAction.ScrollBy` in this container. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .semantics { + testTag = "tabbar_container" + listLength = TabBarItem.entries.size + }, + ) { + TabBarItem.entries.forEachIndexed { index, tabBarItem -> + TabBarItemContent( + item = tabBarItem, + selectedTabId = selectedTabId, + onSelect = onSelect, + modifier = Modifier + .weight(1f) + .listItemIndexSemantic(index) + ) + } + } + } +} + +@Composable +private fun TabBarItemContent( + item: TabBarItem, + selectedTabId: String, + onSelect: (id: String) -> Unit, + modifier: Modifier = Modifier, +) { + val isSelected = item.id == selectedTabId + + Box( + modifier = modifier + .selectable( + selected = isSelected, + role = Role.Tab, + interactionSource = remember { + MutableInteractionSource() + }, + indication = null, + onClick = { onSelect.invoke(item.id) } + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(top = 5.dp, bottom = 4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val iconContent = if (isSelected) item.selectedIcon else item.unselectedIcon + Icon( + imageVector = iconContent, + contentDescription = null, + modifier = Modifier + .testTag("tabbar_item_icon") + .imageContentSemantic(iconContent) + ) + + if (item.counter != null) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${item.counter}", + color = Color.White, + modifier = Modifier + .background(Color.Red) + ) + } + } + Spacer(modifier = Modifier.height(3.dp)) + + Text( + text = item.text, + style = MaterialTheme.typography.button, + color = if (isSelected) Color.Black else Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag("tabbar_item_text"), + ) + } + } +} + +private enum class TabBarItem( + val id: String, + val text: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val counter: Int? = null, +) { + Search( + id = "0", + text = "Search", + selectedIcon = Icons.Filled.Search, + unselectedIcon = Icons.Outlined.Search, + ), + Favorites( + id = "1", + text = "Favorites", + selectedIcon = Icons.Filled.Favorite, + unselectedIcon = Icons.Outlined.FavoriteBorder, + counter = 100, + ), + Responses( + id = "2", + text = "Responses", + selectedIcon = Icons.Filled.Email, + unselectedIcon = Icons.Outlined.Email, + ), + Chats( + id = "3", + text = "Chats", + selectedIcon = Icons.Filled.Send, + unselectedIcon = Icons.Outlined.Send, + counter = 9, + ), + Profile( + id = "4", + text = "Profile", + selectedIcon = Icons.Filled.Person, + unselectedIcon = Icons.Outlined.Person, + ), +} + +@Preview +@Composable +private fun PreviewNotScrollableScreen() { + MaterialTheme { + NotScrollableListScreen() + } +} diff --git a/sample/src/main/java/io/github/kakaocup/compose/sample/ScrollableLazyListScreen.kt b/sample/src/main/java/io/github/kakaocup/compose/sample/ScrollableLazyListScreen.kt new file mode 100644 index 00000000..5ba29585 --- /dev/null +++ b/sample/src/main/java/io/github/kakaocup/compose/sample/ScrollableLazyListScreen.kt @@ -0,0 +1,129 @@ +package io.github.kakaocup.compose.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import io.github.kakaocup.compose.sample.semantics.listItemIndexSemantic +import io.github.kakaocup.compose.sample.semantics.listLengthSemantic + +/** + * Sample with scrollable lazy list. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ScrollableLazyListScreen() { + var items by remember { mutableStateOf(listOf()) } + var isRefreshing by remember { mutableStateOf(false) } + + Scaffold { scaffoldPadding -> + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + if (items.isEmpty()) { + items = getItems() + } else { + items += ScrollableLazyListItem.Item("Item ${items.size + 1}") + } + isRefreshing = false + } + ) + + Box( + modifier = Modifier + .padding(scaffoldPadding) + .testTag("PullToRefresh") + .pullRefresh(pullRefreshState) + ) { + LazyColumn( + Modifier + .fillMaxSize() + .testTag("LazyList") + .listLengthSemantic(items.size) + ) { + itemsIndexed(items) { index, item -> + when (item) { + is ScrollableLazyListItem.Header -> ListItemHeader(item, Modifier.listItemIndexSemantic(index)) + is ScrollableLazyListItem.Item -> ListItemCell(item, Modifier.listItemIndexSemantic(index)) + } + } + } + PullRefreshIndicator( + state = pullRefreshState, + refreshing = isRefreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } +} + + + +@Composable +private fun ListItemHeader(item: ScrollableLazyListItem.Header, modifier: Modifier = Modifier) { + Box( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Green) + .then(modifier) + ) { + Text( + item.title, + Modifier + .padding(8.dp) + .testTag("LazyListHeaderTitle") + ) + } +} + +@Composable +private fun ListItemCell(item: ScrollableLazyListItem.Item, modifier: Modifier = Modifier) { + Text( + item.title, + Modifier + .fillMaxWidth() + .padding(16.dp) + .testTag("LazyListItemTitle") + .then(modifier) + ) +} + +private fun getItems(): List { + val result = mutableListOf() + + repeat(30) { index -> + if (index % 10 == 0) { + result += ScrollableLazyListItem.Header(title = "Items from ${index + 1} to ${index + 10}") + } + result += ScrollableLazyListItem.Item("Item ${index + 1}") + } + + return result +} + +private sealed class ScrollableLazyListItem { + data class Header(val title: String) : ScrollableLazyListItem() + data class Item(val title: String) : ScrollableLazyListItem() +} diff --git a/sample/src/main/java/io/github/kakaocup/compose/sample/ScrollableListScreen.kt b/sample/src/main/java/io/github/kakaocup/compose/sample/ScrollableListScreen.kt new file mode 100644 index 00000000..52aa4ce0 --- /dev/null +++ b/sample/src/main/java/io/github/kakaocup/compose/sample/ScrollableListScreen.kt @@ -0,0 +1,129 @@ +package io.github.kakaocup.compose.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import io.github.kakaocup.compose.sample.semantics.listItemIndexSemantic +import io.github.kakaocup.compose.sample.semantics.listLengthSemantic + +/** + * Copy of [ScrollableLazyListScreen] but with not lazy [androidx.compose.foundation.layout.Column]. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ScrollableListScreen() { + var items by remember { mutableStateOf(listOf()) } + var isRefreshing by remember { mutableStateOf(false) } + + Scaffold { scaffoldPadding -> + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + if (items.isEmpty()) { + items = getItems() + } else { + items += ScrollableListItem.Item("Item ${items.size + 1}") + } + isRefreshing = false + } + ) + + Box( + modifier = Modifier + .padding(scaffoldPadding) + .testTag("PullToRefresh") + .pullRefresh(pullRefreshState) + ) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .testTag("ScrollableColumn") + .listLengthSemantic(items.size) + ) { + items.forEachIndexed { index, item -> + when (item) { + is ScrollableListItem.Header -> ListItemHeader(item, Modifier.listItemIndexSemantic(index)) + is ScrollableListItem.Item -> ListItemCell(item, Modifier.listItemIndexSemantic(index)) + } + } + } + PullRefreshIndicator( + state = pullRefreshState, + refreshing = isRefreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } +} + +@Composable +private fun ListItemHeader(item: ScrollableListItem.Header, modifier: Modifier = Modifier) { + Box( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Green) + .then(modifier) + ) { + Text( + item.title, + Modifier + .padding(8.dp) + .testTag("ListHeaderTitle") + ) + } +} + +@Composable +private fun ListItemCell(item: ScrollableListItem.Item, modifier: Modifier = Modifier) { + Text( + item.title, + Modifier + .fillMaxWidth() + .padding(16.dp) + .testTag("ListItemTitle") + .then(modifier) + ) +} + +private fun getItems(): List { + val result = mutableListOf() + + repeat(30) { index -> + if (index % 10 == 0) { + result += ScrollableListItem.Header(title = "Items from ${index + 1} to ${index + 10}") + } + result += ScrollableListItem.Item("Item ${index + 1}") + } + + return result +} + +private sealed class ScrollableListItem { + data class Header(val title: String) : ScrollableListItem() + data class Item(val title: String) : ScrollableListItem() +} diff --git a/sample/src/main/java/io/github/kakaocup/compose/sample/semantics/ListItemIndex.kt b/sample/src/main/java/io/github/kakaocup/compose/sample/semantics/ListItemIndex.kt new file mode 100644 index 00000000..c7b55a71 --- /dev/null +++ b/sample/src/main/java/io/github/kakaocup/compose/sample/semantics/ListItemIndex.kt @@ -0,0 +1,12 @@ +package io.github.kakaocup.compose.sample.semantics + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics + +val ListItemIndexSemanticKey = SemanticsPropertyKey("ListItemIndex") +var SemanticsPropertyReceiver.listItemIndex by ListItemIndexSemanticKey +fun Modifier.listItemIndexSemantic(index: Int): Modifier { + return semantics { listItemIndex = index } +} diff --git a/sample/src/main/java/io/github/kakaocup/compose/sample/semantics/ListLength.kt b/sample/src/main/java/io/github/kakaocup/compose/sample/semantics/ListLength.kt new file mode 100644 index 00000000..3a56f216 --- /dev/null +++ b/sample/src/main/java/io/github/kakaocup/compose/sample/semantics/ListLength.kt @@ -0,0 +1,12 @@ +package io.github.kakaocup.compose.sample.semantics + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics + +val ListLengthSemanticKey = SemanticsPropertyKey("ListLength") +var SemanticsPropertyReceiver.listLength by ListLengthSemanticKey +fun Modifier.listLengthSemantic(length: Int): Modifier { + return semantics { listLength = length } +}