blob: 37733b7bc7ab3a20daafd2161bc790e043431075 [file] [log] [blame]
/*
* Copyright (C) 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 com.android.calendar
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.AbsListView.OnScrollListener
import android.widget.Adapter
import android.widget.FrameLayout
import android.widget.ListView
/**
* Implements a ListView class with a sticky header at the top. The header is
* per section and it is pinned to the top as long as its section is at the top
* of the view. If it is not, the header slides up or down (depending on the
* scroll movement) and the header of the current section slides to the top.
* Notes:
* 1. The class uses the first available child ListView as the working
* ListView. If no ListView child exists, the class will create a default one.
* 2. The ListView's adapter must be passed to this class using the 'setAdapter'
* method. The adapter must implement the HeaderIndexer interface. If no adapter
* is specified, the class will try to extract it from the ListView
* 3. The class registers itself as a listener to scroll events (OnScrollListener), if the
* ListView needs to receive scroll events, it must register its listener using
* this class' setOnScrollListener method.
* 4. Headers for the list view must be added before using the StickyHeaderListView
* 5. The implementation should register to listen to dataset changes. Right now this is not done
* since a change the dataset in a listview forces a call to OnScroll. The needed code is
* commented out.
*/
class StickyHeaderListView(context: Context, attrs: AttributeSet?) :
FrameLayout(context, attrs), OnScrollListener {
protected var mChildViewsCreated = false
protected var mDoHeaderReset = false
protected var mContext: Context? = null
protected var mAdapter: Adapter? = null
protected var mIndexer: HeaderIndexer? = null
protected var mHeaderHeightListener: HeaderHeightListener? = null
protected var mStickyHeader: View? = null
// A invisible header used when a section has no header
protected var mNonessentialHeader: View? = null
protected var mListView: ListView? = null
protected var mListener: AbsListView.OnScrollListener? = null
private var mSeparatorWidth = 0
private var mSeparatorView: View? = null
private var mLastStickyHeaderHeight = 0
// This code is needed only if dataset changes do not force a call to OnScroll
// protected DataSetObserver mListDataObserver = null;
protected var mCurrentSectionPos = -1 // Position of section that has its header on the
// top of the view
protected var mNextSectionPosition = -1 // Position of next section's header
protected var mListViewHeadersCount = 0
/**
* Interface that must be implemented by the ListView adapter to provide headers locations
* and number of items under each header.
*
*/
interface HeaderIndexer {
/**
* Calculates the position of the header of a specific item in the adapter's data set.
* For example: Assuming you have a list with albums and songs names:
* Album A, song 1, song 2, ...., song 10, Album B, song 1, ..., song 7. A call to
* this method with the position of song 5 in Album B, should return the position
* of Album B.
* @param position - Position of the item in the ListView dataset
* @return Position of header. -1 if the is no header
*/
fun getHeaderPositionFromItemPosition(position: Int): Int
/**
* Calculates the number of items in the section defined by the header (not including
* the header).
* For example: A list with albums and songs, the method should return
* the number of songs names (without the album name).
*
* @param headerPosition - the value returned by 'getHeaderPositionFromItemPosition'
* @return Number of items. -1 on error.
*/
fun getHeaderItemsNumber(headerPosition: Int): Int
}
/***
*
* Interface that is used to update the sticky header's height
*
*/
interface HeaderHeightListener {
/***
* Updated a change in the sticky header's size
*
* @param height - new height of sticky header
*/
fun OnHeaderHeightChanged(height: Int)
}
/**
* Sets the adapter to be used by the class to get views of headers
*
* @param adapter - The adapter.
*/
fun setAdapter(adapter: Adapter?) {
// This code is needed only if dataset changes do not force a call to
// OnScroll
// if (mAdapter != null && mListDataObserver != null) {
// mAdapter.unregisterDataSetObserver(mListDataObserver);
// }
if (adapter != null) {
mAdapter = adapter
// This code is needed only if dataset changes do not force a call
// to OnScroll
// mAdapter.registerDataSetObserver(mListDataObserver);
}
}
/**
* Sets the indexer object (that implements the HeaderIndexer interface).
*
* @param indexer - The indexer.
*/
fun setIndexer(indexer: HeaderIndexer?) {
mIndexer = indexer
}
/**
* Sets the list view that is displayed
* @param lv - The list view.
*/
fun setListView(lv: ListView?) {
mListView = lv
mListView?.setOnScrollListener(this)
mListViewHeadersCount = mListView?.getHeaderViewsCount() as Int
}
/**
* Sets an external OnScroll listener. Since the StickyHeaderListView sets
* itself as the scroll events listener of the listview, this method allows
* the user to register another listener that will be called after this
* class listener is called.
*
* @param listener - The external listener.
*/
fun setOnScrollListener(listener: AbsListView.OnScrollListener?) {
mListener = listener
}
fun setHeaderHeightListener(listener: HeaderHeightListener?) {
mHeaderHeightListener = listener
}
/**
* Scroll status changes listener
*
* @param view - the scrolled view
* @param scrollState - new scroll state.
*/
@Override
override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {
if (mListener != null) {
mListener?.onScrollStateChanged(view, scrollState)
}
}
/**
* Scroll events listener
*
* @param view - the scrolled view
* @param firstVisibleItem - the index (in the list's adapter) of the top
* visible item.
* @param visibleItemCount - the number of visible items in the list
* @param totalItemCount - the total number items in the list
*/
@Override
override fun onScroll(
view: AbsListView?,
firstVisibleItem: Int,
visibleItemCount: Int,
totalItemCount: Int
) {
updateStickyHeader(firstVisibleItem)
if (mListener != null) {
mListener?.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount)
}
}
/**
* Sets a separator below the sticky header, which will be visible while the sticky header
* is not scrolling up.
* @param color - color of separator
* @param width - width in pixels of separator
*/
fun setHeaderSeparator(color: Int, width: Int) {
mSeparatorView = View(mContext)
val params: ViewGroup.LayoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
width, Gravity.TOP
)
mSeparatorView?.setLayoutParams(params)
mSeparatorView?.setBackgroundColor(color)
mSeparatorWidth = width
this.addView(mSeparatorView)
}
protected fun updateStickyHeader(firstVisibleItemInput: Int) {
// Try to make sure we have an adapter to work with (may not succeed).
var firstVisibleItem = firstVisibleItemInput
if (mAdapter == null && mListView != null) {
setAdapter(mListView?.getAdapter())
}
firstVisibleItem -= mListViewHeadersCount
if (mAdapter != null && mIndexer != null && mDoHeaderReset) {
// Get the section header position
var sectionSize = 0
val sectionPos = mIndexer!!.getHeaderPositionFromItemPosition(firstVisibleItem)
// New section - set it in the header view
var newView = false
if (sectionPos != mCurrentSectionPos) {
// No header for current position , use the nonessential invisible one,
// hide the separator
if (sectionPos == -1) {
sectionSize = 0
this.removeView(mStickyHeader)
mStickyHeader = mNonessentialHeader
if (mSeparatorView != null) {
mSeparatorView?.setVisibility(View.GONE)
}
newView = true
} else {
// Create a copy of the header view to show on top
sectionSize = mIndexer!!.getHeaderItemsNumber(sectionPos)
val v: View? =
mAdapter?.getView(sectionPos + mListViewHeadersCount, null, mListView)
v?.measure(
MeasureSpec.makeMeasureSpec(
mListView?.getWidth() as Int,
MeasureSpec.EXACTLY
), MeasureSpec.makeMeasureSpec(
mListView?.getHeight() as Int,
MeasureSpec.AT_MOST
)
)
this.removeView(mStickyHeader)
mStickyHeader = v
newView = true
}
mCurrentSectionPos = sectionPos
mNextSectionPosition = sectionSize + sectionPos + 1
}
// Do transitions
// If position of bottom of last item in a section is smaller than the height of the
// sticky header - shift drawable of header.
if (mStickyHeader != null) {
val sectionLastItemPosition = mNextSectionPosition - firstVisibleItem - 1
var stickyHeaderHeight: Int = mStickyHeader?.getHeight() as Int
if (stickyHeaderHeight == 0) {
stickyHeaderHeight = mStickyHeader?.getMeasuredHeight() as Int
}
// Update new header height
if (mHeaderHeightListener != null &&
mLastStickyHeaderHeight != stickyHeaderHeight
) {
mLastStickyHeaderHeight = stickyHeaderHeight
mHeaderHeightListener!!.OnHeaderHeightChanged(stickyHeaderHeight)
}
val SectionLastView: View? = mListView?.getChildAt(sectionLastItemPosition)
if (SectionLastView != null && SectionLastView.getBottom() <= stickyHeaderHeight) {
val lastViewBottom: Int = SectionLastView.getBottom()
mStickyHeader?.setTranslationY(lastViewBottom.toFloat() -
stickyHeaderHeight.toFloat())
if (mSeparatorView != null) {
mSeparatorView?.setVisibility(View.GONE)
}
} else if (stickyHeaderHeight != 0) {
mStickyHeader?.setTranslationY(0f)
if (mSeparatorView != null &&
mStickyHeader?.equals(mNonessentialHeader) == false) {
mSeparatorView?.setVisibility(View.VISIBLE)
}
}
if (newView) {
mStickyHeader?.setVisibility(View.INVISIBLE)
this.addView(mStickyHeader)
if (mSeparatorView != null &&
mStickyHeader?.equals(mNonessentialHeader) == false) {
val params: FrameLayout.LayoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
mSeparatorWidth
)
params.setMargins(0, mStickyHeader?.getMeasuredHeight() as Int, 0, 0)
mSeparatorView?.setLayoutParams(params)
mSeparatorView?.setVisibility(View.VISIBLE)
}
mStickyHeader?.setVisibility(View.VISIBLE)
}
}
}
}
@Override
protected override fun onFinishInflate() {
super.onFinishInflate()
if (!mChildViewsCreated) {
setChildViews()
}
mDoHeaderReset = true
}
@Override
protected override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!mChildViewsCreated) {
setChildViews()
}
mDoHeaderReset = true
}
// Resets the sticky header when the adapter data set was changed
// This code is needed only if dataset changes do not force a call to OnScroll
// protected void onDataChanged() {
// Should do a call to updateStickyHeader if needed
// }
private fun setChildViews() {
// Find a child ListView (if any)
val iChildNum: Int = getChildCount()
for (i in 0 until iChildNum) {
val v: Object = getChildAt(i) as Object
if (v is ListView) {
setListView(v as ListView)
}
}
// No child ListView - add one
if (mListView == null) {
setListView(ListView(mContext))
}
// Create a nonessential view , it will be used in case a section has no header
mNonessentialHeader = View(mContext)
val params: ViewGroup.LayoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
1, Gravity.TOP
)
mNonessentialHeader?.setLayoutParams(params)
mNonessentialHeader?.setBackgroundColor(Color.TRANSPARENT)
mChildViewsCreated = true
}
companion object {
private const val TAG = "StickyHeaderListView"
}
/**
* Constructor
*
* @param context - application context.
* @param attrs - layout attributes.
*/
init {
mContext = context
}
}