1 /*
2  * Copyright (C) 2021 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 package com.android.calendar
17 
18 import android.content.Context
19 import android.graphics.Color
20 import android.util.AttributeSet
21 import android.view.Gravity
22 import android.view.View
23 import android.view.ViewGroup
24 import android.widget.AbsListView
25 import android.widget.AbsListView.OnScrollListener
26 import android.widget.Adapter
27 import android.widget.FrameLayout
28 import android.widget.ListView
29 
30 /**
31  * Implements a ListView class with a sticky header at the top. The header is
32  * per section and it is pinned to the top as long as its section is at the top
33  * of the view. If it is not, the header slides up or down (depending on the
34  * scroll movement) and the header of the current section slides to the top.
35  * Notes:
36  * 1. The class uses the first available child ListView as the working
37  * ListView. If no ListView child exists, the class will create a default one.
38  * 2. The ListView's adapter must be passed to this class using the 'setAdapter'
39  * method. The adapter must implement the HeaderIndexer interface. If no adapter
40  * is specified, the class will try to extract it from the ListView
41  * 3. The class registers itself as a listener to scroll events (OnScrollListener), if the
42  * ListView needs to receive scroll events, it must register its listener using
43  * this class' setOnScrollListener method.
44  * 4. Headers for the list view must be added before using the StickyHeaderListView
45  * 5. The implementation should register to listen to dataset changes. Right now this is not done
46  * since a change the dataset in a listview forces a call to OnScroll. The needed code is
47  * commented out.
48  */
49 class StickyHeaderListView(context: Context, attrs: AttributeSet?) :
50     FrameLayout(context, attrs), OnScrollListener {
51     protected var mChildViewsCreated = false
52     protected var mDoHeaderReset = false
53     protected var mContext: Context? = null
54     protected var mAdapter: Adapter? = null
55     protected var mIndexer: HeaderIndexer? = null
56     protected var mHeaderHeightListener: HeaderHeightListener? = null
57     protected var mStickyHeader: View? = null
58     // A invisible header used when a section has no header
59     protected var mNonessentialHeader: View? = null
60     protected var mListView: ListView? = null
61     protected var mListener: AbsListView.OnScrollListener? = null
62     private var mSeparatorWidth = 0
63     private var mSeparatorView: View? = null
64     private var mLastStickyHeaderHeight = 0
65 
66     // This code is needed only if dataset changes do not force a call to OnScroll
67     // protected DataSetObserver mListDataObserver = null;
68     protected var mCurrentSectionPos = -1 // Position of section that has its header on the
69 
70     // top of the view
71     protected var mNextSectionPosition = -1 // Position of next section's header
72     protected var mListViewHeadersCount = 0
73 
74     /**
75      * Interface that must be implemented by the ListView adapter to provide headers locations
76      * and number of items under each header.
77      *
78      */
79     interface HeaderIndexer {
80         /**
81          * Calculates the position of the header of a specific item in the adapter's data set.
82          * For example: Assuming you have a list with albums and songs names:
83          * Album A, song 1, song 2, ...., song 10, Album B, song 1, ..., song 7. A call to
84          * this method with the position of song 5 in Album B, should return  the position
85          * of Album B.
86          * @param position - Position of the item in the ListView dataset
87          * @return Position of header. -1 if the is no header
88          */
getHeaderPositionFromItemPositionnull89         fun getHeaderPositionFromItemPosition(position: Int): Int
90 
91         /**
92          * Calculates the number of items in the section defined by the header (not including
93          * the header).
94          * For example: A list with albums and songs, the method should return
95          * the number of songs names (without the album name).
96          *
97          * @param headerPosition - the value returned by 'getHeaderPositionFromItemPosition'
98          * @return Number of items. -1 on error.
99          */
100         fun getHeaderItemsNumber(headerPosition: Int): Int
101     }
102 
103     /***
104      *
105      * Interface that is used to update the sticky header's height
106      *
107      */
108     interface HeaderHeightListener {
109         /***
110          * Updated a change in the sticky header's size
111          *
112          * @param height - new height of sticky header
113          */
114         fun OnHeaderHeightChanged(height: Int)
115     }
116 
117     /**
118      * Sets the adapter to be used by the class to get views of headers
119      *
120      * @param adapter - The adapter.
121      */
setAdapternull122     fun setAdapter(adapter: Adapter?) {
123         // This code is needed only if dataset changes do not force a call to
124         // OnScroll
125         // if (mAdapter != null && mListDataObserver != null) {
126         // mAdapter.unregisterDataSetObserver(mListDataObserver);
127         // }
128         if (adapter != null) {
129             mAdapter = adapter
130             // This code is needed only if dataset changes do not force a call
131             // to OnScroll
132             // mAdapter.registerDataSetObserver(mListDataObserver);
133         }
134     }
135 
136     /**
137      * Sets the indexer object (that implements the HeaderIndexer interface).
138      *
139      * @param indexer - The indexer.
140      */
setIndexernull141     fun setIndexer(indexer: HeaderIndexer?) {
142         mIndexer = indexer
143     }
144 
145     /**
146      * Sets the list view that is displayed
147      * @param lv - The list view.
148      */
setListViewnull149     fun setListView(lv: ListView?) {
150         mListView = lv
151         mListView?.setOnScrollListener(this)
152         mListViewHeadersCount = mListView?.getHeaderViewsCount() as Int
153     }
154 
155     /**
156      * Sets an external OnScroll listener. Since the StickyHeaderListView sets
157      * itself as the scroll events listener of the listview, this method allows
158      * the user to register another listener that will be called after this
159      * class listener is called.
160      *
161      * @param listener - The external listener.
162      */
setOnScrollListenernull163     fun setOnScrollListener(listener: AbsListView.OnScrollListener?) {
164         mListener = listener
165     }
166 
setHeaderHeightListenernull167     fun setHeaderHeightListener(listener: HeaderHeightListener?) {
168         mHeaderHeightListener = listener
169     }
170 
171     /**
172      * Scroll status changes listener
173      *
174      * @param view - the scrolled view
175      * @param scrollState - new scroll state.
176      */
177     @Override
onScrollStateChangednull178     override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {
179         if (mListener != null) {
180             mListener?.onScrollStateChanged(view, scrollState)
181         }
182     }
183 
184     /**
185      * Scroll events listener
186      *
187      * @param view - the scrolled view
188      * @param firstVisibleItem - the index (in the list's adapter) of the top
189      * visible item.
190      * @param visibleItemCount - the number of visible items in the list
191      * @param totalItemCount - the total number items in the list
192      */
193     @Override
onScrollnull194     override fun onScroll(
195         view: AbsListView?,
196         firstVisibleItem: Int,
197         visibleItemCount: Int,
198         totalItemCount: Int
199     ) {
200         updateStickyHeader(firstVisibleItem)
201         if (mListener != null) {
202             mListener?.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount)
203         }
204     }
205 
206     /**
207      * Sets a separator below the sticky header, which will be visible while the sticky header
208      * is not scrolling up.
209      * @param color - color of separator
210      * @param width - width in pixels of separator
211      */
setHeaderSeparatornull212     fun setHeaderSeparator(color: Int, width: Int) {
213         mSeparatorView = View(mContext)
214         val params: ViewGroup.LayoutParams = LayoutParams(
215             LayoutParams.MATCH_PARENT,
216             width, Gravity.TOP
217         )
218         mSeparatorView?.setLayoutParams(params)
219         mSeparatorView?.setBackgroundColor(color)
220         mSeparatorWidth = width
221         this.addView(mSeparatorView)
222     }
223 
updateStickyHeadernull224     protected fun updateStickyHeader(firstVisibleItemInput: Int) {
225         // Try to make sure we have an adapter to work with (may not succeed).
226         var firstVisibleItem = firstVisibleItemInput
227         if (mAdapter == null && mListView != null) {
228             setAdapter(mListView?.getAdapter())
229         }
230         firstVisibleItem -= mListViewHeadersCount
231         if (mAdapter != null && mIndexer != null && mDoHeaderReset) {
232 
233             // Get the section header position
234             var sectionSize = 0
235             val sectionPos = mIndexer!!.getHeaderPositionFromItemPosition(firstVisibleItem)
236 
237             // New section - set it in the header view
238             var newView = false
239             if (sectionPos != mCurrentSectionPos) {
240 
241                 // No header for current position , use the nonessential invisible one,
242                 // hide the separator
243                 if (sectionPos == -1) {
244                     sectionSize = 0
245                     this.removeView(mStickyHeader)
246                     mStickyHeader = mNonessentialHeader
247                     if (mSeparatorView != null) {
248                         mSeparatorView?.setVisibility(View.GONE)
249                     }
250                     newView = true
251                 } else {
252                     // Create a copy of the header view to show on top
253                     sectionSize = mIndexer!!.getHeaderItemsNumber(sectionPos)
254                     val v: View? =
255                         mAdapter?.getView(sectionPos + mListViewHeadersCount, null, mListView)
256                     v?.measure(
257                         MeasureSpec.makeMeasureSpec(
258                             mListView?.getWidth() as Int,
259                             MeasureSpec.EXACTLY
260                         ), MeasureSpec.makeMeasureSpec(
261                             mListView?.getHeight() as Int,
262                             MeasureSpec.AT_MOST
263                         )
264                     )
265                     this.removeView(mStickyHeader)
266                     mStickyHeader = v
267                     newView = true
268                 }
269                 mCurrentSectionPos = sectionPos
270                 mNextSectionPosition = sectionSize + sectionPos + 1
271             }
272 
273             // Do transitions
274             // If position of bottom of last item in a section is smaller than the height of the
275             // sticky header - shift drawable of header.
276             if (mStickyHeader != null) {
277                 val sectionLastItemPosition = mNextSectionPosition - firstVisibleItem - 1
278                 var stickyHeaderHeight: Int = mStickyHeader?.getHeight() as Int
279                 if (stickyHeaderHeight == 0) {
280                     stickyHeaderHeight = mStickyHeader?.getMeasuredHeight() as Int
281                 }
282 
283                 // Update new header height
284                 if (mHeaderHeightListener != null &&
285                     mLastStickyHeaderHeight != stickyHeaderHeight
286                 ) {
287                     mLastStickyHeaderHeight = stickyHeaderHeight
288                     mHeaderHeightListener!!.OnHeaderHeightChanged(stickyHeaderHeight)
289                 }
290                 val SectionLastView: View? = mListView?.getChildAt(sectionLastItemPosition)
291                 if (SectionLastView != null && SectionLastView.getBottom() <= stickyHeaderHeight) {
292                     val lastViewBottom: Int = SectionLastView.getBottom()
293                     mStickyHeader?.setTranslationY(lastViewBottom.toFloat() -
294                         stickyHeaderHeight.toFloat())
295                     if (mSeparatorView != null) {
296                         mSeparatorView?.setVisibility(View.GONE)
297                     }
298                 } else if (stickyHeaderHeight != 0) {
299                     mStickyHeader?.setTranslationY(0f)
300                     if (mSeparatorView != null &&
301                         mStickyHeader?.equals(mNonessentialHeader) == false) {
302                         mSeparatorView?.setVisibility(View.VISIBLE)
303                     }
304                 }
305                 if (newView) {
306                     mStickyHeader?.setVisibility(View.INVISIBLE)
307                     this.addView(mStickyHeader)
308                     if (mSeparatorView != null &&
309                         mStickyHeader?.equals(mNonessentialHeader) == false) {
310                         val params: FrameLayout.LayoutParams = LayoutParams(
311                             LayoutParams.MATCH_PARENT,
312                             mSeparatorWidth
313                         )
314                         params.setMargins(0, mStickyHeader?.getMeasuredHeight() as Int, 0, 0)
315                         mSeparatorView?.setLayoutParams(params)
316                         mSeparatorView?.setVisibility(View.VISIBLE)
317                     }
318                     mStickyHeader?.setVisibility(View.VISIBLE)
319                 }
320             }
321         }
322     }
323 
324     @Override
onFinishInflatenull325     protected override fun onFinishInflate() {
326         super.onFinishInflate()
327         if (!mChildViewsCreated) {
328             setChildViews()
329         }
330         mDoHeaderReset = true
331     }
332 
333     @Override
onAttachedToWindownull334     protected override fun onAttachedToWindow() {
335         super.onAttachedToWindow()
336         if (!mChildViewsCreated) {
337             setChildViews()
338         }
339         mDoHeaderReset = true
340     }
341 
342     // Resets the sticky header when the adapter data set was changed
343     // This code is needed only if dataset changes do not force a call to OnScroll
344     // protected void onDataChanged() {
345     // Should do a call to updateStickyHeader if needed
346     // }
setChildViewsnull347     private fun setChildViews() {
348         // Find a child ListView (if any)
349         val iChildNum: Int = getChildCount()
350         for (i in 0 until iChildNum) {
351             val v: Object = getChildAt(i) as Object
352             if (v is ListView) {
353                 setListView(v as ListView)
354             }
355         }
356 
357         // No child ListView - add one
358         if (mListView == null) {
359             setListView(ListView(mContext))
360         }
361 
362         // Create a nonessential view , it will be used in case a section has no header
363         mNonessentialHeader = View(mContext)
364         val params: ViewGroup.LayoutParams = LayoutParams(
365             LayoutParams.MATCH_PARENT,
366             1, Gravity.TOP
367         )
368         mNonessentialHeader?.setLayoutParams(params)
369         mNonessentialHeader?.setBackgroundColor(Color.TRANSPARENT)
370         mChildViewsCreated = true
371     }
372 
373     companion object {
374         private const val TAG = "StickyHeaderListView"
375     }
376 
377     /**
378      * Constructor
379      *
380      * @param context - application context.
381      * @param attrs - layout attributes.
382      */
383     init {
384         mContext = context
385     }
386 }