diff --git a/CHANGELOG.md b/CHANGELOG.md index 1afff727d..9ac925982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Please add your entries according to this format. * Add support for GraphQL [#805] [#884] * Show GraphQL OperationName header to transaction title [#69], [#116] * Allows to filter transactions based on graphqlOperationName [#920] +* Added scroll to highlighted text search in response screen [#988] ### Fixed @@ -557,3 +558,5 @@ Initial release. [#930]: https://github.com/ChuckerTeam/chucker/pull/930 [#970]: https://github.com/ChuckerTeam/chucker/pull/970 [#975]: https://github.com/ChuckerTeam/chucker/pull/975 +[#388]: https://github.com/ChuckerTeam/chucker/issues/388 +[#988]: https://github.com/ChuckerTeam/chucker/pull/988 diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt index ff8e70614..c36c9fc28 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt @@ -5,6 +5,7 @@ import android.text.Spanned import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.UnderlineSpan +import java.util.regex.Pattern /** * Highlight parts of the String when it matches the search. @@ -13,21 +14,55 @@ import android.text.style.UnderlineSpan */ internal fun SpannableStringBuilder.highlightWithDefinedColors( search: String, + startIndices: List, backgroundColor: Int, foregroundColor: Int ): SpannableStringBuilder { - val startIndexes = indexesOf(this.toString(), search) - return applyColoredSpannable(this, startIndexes, search.length, backgroundColor, foregroundColor) + return applyColoredSpannable(this, startIndices, search.length, backgroundColor, foregroundColor) } -private fun indexesOf(text: String, search: String): List { - val startPositions = mutableListOf() - var index = text.indexOf(search, 0, true) - while (index >= 0) { - startPositions.add(index) - index = text.indexOf(search, index + 1, true) +internal fun CharSequence.indicesOf(input: String): List = + Pattern.compile(input, Pattern.CASE_INSENSITIVE).toRegex() + .findAll(this) + .map { it.range.first } + .toCollection(mutableListOf()) + +internal fun SpannableStringBuilder.highlightWithDefinedColorsSubstring( + search: String, + startIndex: Int, + backgroundColor: Int, + foregroundColor: Int +): SpannableStringBuilder { + return applyColoredSpannableSubstring(this, startIndex, search.length, backgroundColor, foregroundColor) +} + +private fun applyColoredSpannableSubstring( + text: SpannableStringBuilder, + subStringStartPosition: Int, + subStringLength: Int, + backgroundColor: Int, + foregroundColor: Int +): SpannableStringBuilder { + return text.apply { + setSpan( + UnderlineSpan(), + subStringStartPosition, + subStringStartPosition + subStringLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + setSpan( + ForegroundColorSpan(foregroundColor), + subStringStartPosition, + subStringStartPosition + subStringLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + setSpan( + BackgroundColorSpan(backgroundColor), + subStringStartPosition, + subStringStartPosition + subStringLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) } - return startPositions } private fun applyColoredSpannable( @@ -37,26 +72,9 @@ private fun applyColoredSpannable( backgroundColor: Int, foregroundColor: Int ): SpannableStringBuilder { - return indexes - .fold(text) { builder, position -> - builder.setSpan( - UnderlineSpan(), - position, - position + length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - builder.setSpan( - ForegroundColorSpan(foregroundColor), - position, - position + length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - builder.setSpan( - BackgroundColorSpan(backgroundColor), - position, - position + length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - builder + return text.apply { + indexes.forEach { + applyColoredSpannableSubstring(text, it, length, backgroundColor, foregroundColor) } + } } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/ViewUtils.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/ViewUtils.kt new file mode 100644 index 000000000..ffa8471f8 --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/ViewUtils.kt @@ -0,0 +1,15 @@ +package com.chuckerteam.chucker.internal.support + +import android.view.View + +internal fun View.visible() { + if (this.visibility != View.VISIBLE) { + this.visibility = View.VISIBLE + } +} + +internal fun View.gone() { + if (this.visibility != View.GONE) { + this.visibility = View.GONE + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPagerAdapter.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPagerAdapter.kt index c5224cc92..7c88ce5d3 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPagerAdapter.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPagerAdapter.kt @@ -24,5 +24,5 @@ internal class TransactionPagerAdapter(context: Context, fm: FragmentManager) : override fun getCount(): Int = titles.size - override fun getPageTitle(position: Int): CharSequence? = titles[position] + override fun getPageTitle(position: Int): CharSequence = titles[position] } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt index f48803454..e4483fd38 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt @@ -16,6 +16,8 @@ import com.chuckerteam.chucker.databinding.ChuckerTransactionItemImageBinding import com.chuckerteam.chucker.internal.support.ChessboardDrawable import com.chuckerteam.chucker.internal.support.SpanTextUtil import com.chuckerteam.chucker.internal.support.highlightWithDefinedColors +import com.chuckerteam.chucker.internal.support.highlightWithDefinedColorsSubstring +import com.chuckerteam.chucker.internal.support.indicesOf /** * Adapter responsible of showing the content of the Transaction Request/Response body. @@ -45,10 +47,12 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter { val bodyItemBinding = ChuckerTransactionItemBodyLineBinding.inflate(inflater, parent, false) TransactionPayloadViewHolder.BodyLineViewHolder(bodyItemBinding) } + else -> { val imageItemBinding = ChuckerTransactionItemImageBinding.inflate(inflater, parent, false) TransactionPayloadViewHolder.ImageViewHolder(imageItemBinding) @@ -66,15 +70,35 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter { + val listOfSearchItems = arrayListOf() items.filterIsInstance() .withIndex() .forEach { (index, item) -> - if (item.line.contains(newText, ignoreCase = true)) { + val listOfOccurrences = item.line.indicesOf(newText) + if (listOfOccurrences.isNotEmpty()) { + // storing the occurrences and their positions + listOfOccurrences.forEach { + listOfSearchItems.add( + SearchItemBodyLine( + indexBodyLine = index + 1, + indexStartOfQuerySubString = it + ) + ) + } + + // highlighting the occurrences item.line.clearHighlightSpans() - item.line = - item.line - .highlightWithDefinedColors(newText, backgroundColor, foregroundColor) + item.line = item.line.highlightWithDefinedColors( + newText, + listOfOccurrences, + backgroundColor, + foregroundColor + ) notifyItemChanged(index + 1) } else { // Let's clear the spans if we haven't found the query string. @@ -84,6 +108,26 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter() + private var currentSearchScrollIndex = -1 + private var currentSearchQuery: String = "" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -117,6 +132,32 @@ internal class TransactionPayloadFragment : } } ) + payloadBinding.searchNavButton.setOnClickListener { + onSearchScrollerButtonClick(true) + } + payloadBinding.searchNavButtonUp.setOnClickListener { + onSearchScrollerButtonClick(false) + } + } + + private fun onSearchScrollerButtonClick(goNext: Boolean) { + // hide the keyboard if visible + val inputMethodManager = activity?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + if (inputMethodManager.isAcceptingText) { + activity?.currentFocus?.clearFocus() + inputMethodManager.hideSoftInputFromWindow(view?.windowToken, 0) + } + + if (scrollableIndices.isNotEmpty()) { + val scrollToIndex = + if (goNext) { + ((currentSearchScrollIndex + 1) % scrollableIndices.size) + } else { + (abs(currentSearchScrollIndex - 1 + scrollableIndices.size) % scrollableIndices.size) + } + + scrollToSearchedItemPosition(scrollToIndex) + } } private fun showEmptyState() { @@ -200,18 +241,77 @@ internal class TransactionPayloadFragment : override fun onQueryTextSubmit(query: String): Boolean = false override fun onQueryTextChange(newText: String): Boolean { + scrollableIndices.clear() + currentSearchQuery = newText + currentSearchScrollIndex = -1 + if (newText.isNotBlank() && newText.length > NUMBER_OF_IGNORED_SYMBOLS) { - payloadAdapter.highlightQueryWithColors( - newText, - backgroundSpanColor, - foregroundSpanColor + scrollableIndices.addAll( + payloadAdapter.highlightQueryWithColors(newText, backgroundSpanColor, foregroundSpanColor) ) } else { payloadAdapter.resetHighlight() + makeToolbarSearchSummaryVisible(false) + } + + lifecycleScope.launch { + delay(DELAY_FOR_SEARCH_SCROLL) + lifecycle.withResumed { + if (scrollableIndices.isNotEmpty()) { + scrollToSearchedItemPosition(0) + } else { + currentSearchScrollIndex = -1 + } + } } return true } + private fun makeToolbarSearchSummaryVisible(visible: Boolean = true) { + with(payloadBinding.rootSearchSummary) { + if (visible) visible() else gone() + } + } + + private fun updateToolbarText(searchResultsCount: Int, currentIndex: Int = 1) { + payloadBinding.searchSummary.text = SpannableStringBuilder().apply { + bold { + append("$currentIndex / $searchResultsCount") + } + } + } + + private fun scrollToSearchedItemPosition(positionOfScrollableIndices: Int) { + // reset the last searched item highlight if done + scrollableIndices.getOrNull(currentSearchScrollIndex)?.let { + payloadAdapter.highlightItemWithColorOnPosition( + it.indexBodyLine, + it.indexStartOfQuerySubString, + currentSearchQuery, + backgroundSpanColor, + foregroundSpanColor + ) + } + + currentSearchScrollIndex = positionOfScrollableIndices + val scrollTo = scrollableIndices.getOrNull(positionOfScrollableIndices) + if (scrollTo != null) { + // highlight the next navigated item and update toolbar summary text + payloadAdapter.highlightItemWithColorOnPosition( + scrollTo.indexBodyLine, + scrollTo.indexStartOfQuerySubString, + currentSearchQuery, + backgroundSpanColorSearchItem, + foregroundSpanColor + ) + updateToolbarText(scrollableIndices.size, positionOfScrollableIndices + 1) + makeToolbarSearchSummaryVisible() + + payloadBinding.payloadRecyclerView.smoothScrollToPosition(scrollTo.indexBodyLine) + currentSearchScrollIndex = positionOfScrollableIndices + } + } + private suspend fun processPayload( type: PayloadType, transaction: HttpTransaction, @@ -262,10 +362,12 @@ internal class TransactionPayloadFragment : val text = requireContext().getString(R.string.chucker_body_omitted) result.add(TransactionPayloadItem.BodyLineItem(SpannableStringBuilder.valueOf(text))) } + bodyString.isBlank() -> { val text = requireContext().getString(R.string.chucker_body_empty) result.add(TransactionPayloadItem.BodyLineItem(SpannableStringBuilder.valueOf(text))) } + else -> bodyString.lines().forEach { result.add( TransactionPayloadItem.BodyLineItem( @@ -292,6 +394,7 @@ internal class TransactionPayloadFragment : transaction.requestBody?.byteInputStream()?.copyTo(fos) ?: throw IOException(TRANSACTION_EXCEPTION) } + PayloadType.RESPONSE -> { transaction.responseBody?.byteInputStream()?.copyTo(fos) ?: throw IOException(TRANSACTION_EXCEPTION) @@ -310,6 +413,7 @@ internal class TransactionPayloadFragment : companion object { private const val ARG_TYPE = "type" private const val TRANSACTION_EXCEPTION = "Transaction not ready" + private const val DELAY_FOR_SEARCH_SCROLL: Long = 600L private const val NUMBER_OF_IGNORED_SYMBOLS = 1 diff --git a/library/src/main/res/color/chucker_fab_background_colour.xml b/library/src/main/res/color/chucker_fab_background_colour.xml new file mode 100644 index 000000000..19152c5c3 --- /dev/null +++ b/library/src/main/res/color/chucker_fab_background_colour.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/library/src/main/res/drawable/chucker_ic_arrow_down.xml b/library/src/main/res/drawable/chucker_ic_arrow_down.xml new file mode 100644 index 000000000..f57b4eec5 --- /dev/null +++ b/library/src/main/res/drawable/chucker_ic_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/library/src/main/res/layout/chucker_activity_transaction.xml b/library/src/main/res/layout/chucker_activity_transaction.xml index 04199e278..6c203fa0c 100644 --- a/library/src/main/res/layout/chucker_activity_transaction.xml +++ b/library/src/main/res/layout/chucker_activity_transaction.xml @@ -15,15 +15,15 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" - app:popupTheme="@style/Chucker.Theme" > + app:popupTheme="@style/Chucker.Theme"> + tools:text="Title" /> @@ -41,4 +41,4 @@ android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - \ No newline at end of file + diff --git a/library/src/main/res/layout/chucker_fragment_transaction_payload.xml b/library/src/main/res/layout/chucker_fragment_transaction_payload.xml index 35abe09a8..f1e5ad73f 100755 --- a/library/src/main/res/layout/chucker_fragment_transaction_payload.xml +++ b/library/src/main/res/layout/chucker_fragment_transaction_payload.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:animateLayoutChanges="true" + android:animateLayoutChanges="false" tools:context="com.chuckerteam.chucker.internal.ui.transaction.TransactionPayloadFragment"> + + + + + + + + + + diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml index 961849b64..43b0be5f1 100644 --- a/library/src/main/res/values/strings.xml +++ b/library/src/main/res/values/strings.xml @@ -61,4 +61,6 @@ Chucker can\'t show notifications without permission Change <Unable to discover GraphQL operation name> + Buttons to scroll the search items + Search Results: diff --git a/library/src/main/res/values/styles.xml b/library/src/main/res/values/styles.xml index fbd1011cb..b224594f7 100644 --- a/library/src/main/res/values/styles.xml +++ b/library/src/main/res/values/styles.xml @@ -37,4 +37,8 @@ 2 end + +