1 /*
2  * Copyright 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.input.nestedscroll
18 
19 import androidx.compose.ui.ExperimentalComposeUiApi
20 import androidx.compose.ui.Modifier
21 import androidx.compose.ui.geometry.Offset
22 import androidx.compose.ui.node.DelegatableNode
23 import androidx.compose.ui.node.TraversableNode
24 import androidx.compose.ui.node.findNearestAncestor
25 import androidx.compose.ui.node.traverseAncestors
26 import androidx.compose.ui.unit.Velocity
27 import kotlinx.coroutines.CoroutineScope
28 
29 /**
30  * This creates a Nested Scroll Modifier node that can be delegated to. In most case you should use
31  * [Modifier.nestedScroll] since that implementation also uses this. Use this factory to create
32  * nodes that can be delegated to.
33  */
nestedScrollModifierNodenull34 fun nestedScrollModifierNode(
35     connection: NestedScrollConnection,
36     dispatcher: NestedScrollDispatcher?
37 ): DelegatableNode {
38     return NestedScrollNode(connection, dispatcher)
39 }
40 
41 /** NestedScroll using ModifierLocal as implementation. */
42 internal class NestedScrollNode(
43     var connection: NestedScrollConnection,
44     dispatcher: NestedScrollDispatcher?
45 ) : TraversableNode, NestedScrollConnection, Modifier.Node() {
46 
47     // Resolved dispatcher for re-use in case of null dispatcher is passed.
48     private var resolvedDispatcher: NestedScrollDispatcher
49 
50     init {
51         resolvedDispatcher = dispatcher ?: NestedScrollDispatcher() // Resolve null dispatcher
52     }
53 
54     internal var lastKnownParentNode: NestedScrollNode? = null
55 
56     internal val parentNestedScrollNode: NestedScrollNode?
57         get() = if (isAttached) findNearestAncestor() else null
58 
59     private val parentConnection: NestedScrollConnection?
60         get() = if (isAttached) parentNestedScrollNode else null
61 
62     override val traverseKey: Any = "androidx.compose.ui.input.nestedscroll.NestedScrollNode"
63 
64     private val nestedCoroutineScope: CoroutineScope
65         get() =
66             parentNestedScrollNode?.nestedCoroutineScope
67                 ?: resolvedDispatcher.scope
68                 ?: throw IllegalStateException(
69                     "in order to access nested coroutine scope you need to attach dispatcher to the " +
70                         "`Modifier.nestedScroll` first."
71                 )
72 
onPreScrollnull73     override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
74         val parentPreConsumed = parentConnection?.onPreScroll(available, source) ?: Offset.Zero
75         val selfPreConsumed = connection.onPreScroll(available - parentPreConsumed, source)
76         return parentPreConsumed + selfPreConsumed
77     }
78 
onPostScrollnull79     override fun onPostScroll(
80         consumed: Offset,
81         available: Offset,
82         source: NestedScrollSource
83     ): Offset {
84         val selfConsumed = connection.onPostScroll(consumed, available, source)
85         val parentConsumed =
86             parentConnection?.onPostScroll(
87                 consumed + selfConsumed,
88                 available - selfConsumed,
89                 source
90             ) ?: Offset.Zero
91         return selfConsumed + parentConsumed
92     }
93 
onPreFlingnull94     override suspend fun onPreFling(available: Velocity): Velocity {
95         val parentPreConsumed = parentConnection?.onPreFling(available) ?: Velocity.Zero
96         val selfPreConsumed = connection.onPreFling(available - parentPreConsumed)
97         return parentPreConsumed + selfPreConsumed
98     }
99 
100     @OptIn(ExperimentalComposeUiApi::class)
onPostFlingnull101     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
102         val selfConsumed = connection.onPostFling(consumed, available)
103         // if we receive an onPostFling after detaching this node, use the last known parent
104         // if this parent is also detached it will send the signal through the detached parents
105         val parent = if (isAttached) parentConnection else lastKnownParentNode
106         val parentConsumed =
107             parent?.onPostFling(consumed + selfConsumed, available - selfConsumed) ?: Velocity.Zero
108         return selfConsumed + parentConsumed
109     }
110 
111     // On receiving a new dispatcher, re-setting fields
updateDispatchernull112     private fun updateDispatcher(newDispatcher: NestedScrollDispatcher?) {
113         resetDispatcherFields() // Reset fields of current dispatcher.
114 
115         // Update dispatcher associated with this node.
116         if (newDispatcher == null) {
117             resolvedDispatcher = NestedScrollDispatcher()
118         } else if (newDispatcher != resolvedDispatcher) {
119             resolvedDispatcher = newDispatcher
120         }
121 
122         // Update fields of the newly set dispatcher.
123         if (isAttached) {
124             updateDispatcherFields()
125         }
126     }
127 
onAttachnull128     override fun onAttach() {
129         // NOTE: It is possible for the dispatcher of a yet-to-be-removed node above this one in the
130         // chain is being used here where the dispatcher's modifierLocalNode will not be null. As a
131         // result, we should not check to see if the dispatcher's node is null, we should just set
132         // it assuming that it is not going to be used by the previous node anymore.
133         updateDispatcherFields()
134     }
135 
136     @OptIn(ExperimentalComposeUiApi::class)
onDetachnull137     override fun onDetach() {
138         // cache parent for detached clean up access in the dispatcher and in this node.
139         lastKnownParentNode = findNearestAttachedAncestor()
140         resolvedDispatcher.lastKnownParentNode = lastKnownParentNode
141         resetDispatcherFields()
142     }
143 
144     /**
145      * If the node changes (onAttach) or if the dispatcher changes (node.update). We'll need to
146      * reset the dispatcher properties accordingly.
147      */
148     @OptIn(ExperimentalComposeUiApi::class)
updateDispatcherFieldsnull149     private fun updateDispatcherFields() {
150         resolvedDispatcher.nestedScrollNode = this
151         // reset lastKnownParentNodes
152         resolvedDispatcher.lastKnownParentNode = null
153         lastKnownParentNode = null
154         resolvedDispatcher.calculateNestedScrollScope = { nestedCoroutineScope }
155         resolvedDispatcher.scope = coroutineScope
156     }
157 
resetDispatcherFieldsnull158     private fun resetDispatcherFields() {
159         // only null this out if the modifier local node is what we set it to, since it is possible
160         // it has already been reused in a different node
161         if (resolvedDispatcher.nestedScrollNode === this) resolvedDispatcher.nestedScrollNode = null
162     }
163 
updateNodenull164     internal fun updateNode(
165         connection: NestedScrollConnection,
166         dispatcher: NestedScrollDispatcher?
167     ) {
168         this.connection = connection
169         updateDispatcher(dispatcher)
170     }
171 }
172 
findNearestAttachedAncestornull173 private fun <T : TraversableNode> T.findNearestAttachedAncestor(): T? {
174     var node: T? = null
175     traverseAncestors {
176         if (it.node.isAttached) {
177             node = it
178             false
179         } else {
180             true
181         }
182     }
183     return node
184 }
185