Skip to content

Commit 03b25ea

Browse files
authored
Blur bottom of Input Screen autocomplete (#6394)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1210740454247760?focus=true ### Description - Blurs and fades the bottom of the autocomplete list on API 33+ - Falls back to just a gradient fade on < API 33 ### Steps to test this PR _On API 33+_ - [x] Type something into the input screen - [x] Verify that the bottom of the list is blurred and faded _On < API 33_ - [x] Type something into the input screen - [x] Verify that the bottom of the list is only faded
1 parent 1a1270d commit 03b25ea

File tree

4 files changed

+188
-1
lines changed

4 files changed

+188
-1
lines changed

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/tabs/SearchTabFragment.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717
package com.duckduckgo.duckchat.impl.inputscreen.ui.tabs
1818

19+
import android.os.Build.VERSION
1920
import android.os.Bundle
2021
import android.transition.Transition
2122
import android.view.View
23+
import android.view.View.OVER_SCROLL_NEVER
24+
import android.view.ViewTreeObserver
2225
import android.view.animation.OvershootInterpolator
2326
import androidx.core.content.ContextCompat
2427
import androidx.core.view.isVisible
@@ -36,6 +39,7 @@ import com.duckduckgo.duckchat.impl.databinding.FragmentSearchTabBinding
3639
import com.duckduckgo.duckchat.impl.inputscreen.autocomplete.BrowserAutoCompleteSuggestionsAdapter
3740
import com.duckduckgo.duckchat.impl.inputscreen.autocomplete.OmnibarPosition.TOP
3841
import com.duckduckgo.duckchat.impl.inputscreen.autocomplete.SuggestionItemDecoration
42+
import com.duckduckgo.duckchat.impl.inputscreen.ui.view.BottomBlurView
3943
import com.duckduckgo.duckchat.impl.inputscreen.ui.viewmodel.InputScreenViewModel
4044
import com.duckduckgo.navigation.api.GlobalActivityStarter
4145
import com.duckduckgo.savedsites.api.views.FavoritesGridConfig
@@ -76,6 +80,7 @@ class SearchTabFragment : DuckDuckGoFragment(R.layout.fragment_search_tab) {
7680
configureFavorites()
7781
configureAutoComplete()
7882
configureObservers()
83+
configureBottomBlur()
7984
transition.removeListener(this)
8085
}
8186

@@ -87,6 +92,24 @@ class SearchTabFragment : DuckDuckGoFragment(R.layout.fragment_search_tab) {
8792
)
8893
}
8994

95+
private fun configureBottomBlur() {
96+
if (VERSION.SDK_INT >= 33) {
97+
// TODO: Handle overscroll when blurring
98+
binding.autoCompleteSuggestionsList.overScrollMode = OVER_SCROLL_NEVER
99+
100+
val bottomBlurView = BottomBlurView(requireContext())
101+
bottomBlurView.setTargetView(binding.autoCompleteSuggestionsList)
102+
binding.bottomFadeContainer.addView(bottomBlurView)
103+
104+
ViewTreeObserver.OnPreDrawListener {
105+
bottomBlurView.invalidate()
106+
true
107+
}.also { listener ->
108+
bottomBlurView.viewTreeObserver.addOnPreDrawListener(listener)
109+
}
110+
}
111+
}
112+
90113
private fun configureFavorites() {
91114
val favoritesGridConfig = FavoritesGridConfig(
92115
isExpandable = false,
@@ -148,6 +171,8 @@ class SearchTabFragment : DuckDuckGoFragment(R.layout.fragment_search_tab) {
148171
private fun configureObservers() {
149172
viewModel.visibilityState.onEach {
150173
binding.autoCompleteSuggestionsList.isVisible = it.autoCompleteSuggestionsVisible
174+
binding.bottomFadeContainer.isVisible = it.autoCompleteSuggestionsVisible
175+
151176
if (!it.autoCompleteSuggestionsVisible) {
152177
viewModel.autoCompleteSuggestionsGone()
153178
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
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 com.duckduckgo.duckchat.impl.inputscreen.ui.view
18+
19+
import android.content.Context
20+
import android.graphics.Canvas
21+
import android.graphics.RenderEffect
22+
import android.graphics.RenderNode
23+
import android.graphics.RuntimeShader
24+
import android.util.AttributeSet
25+
import android.view.View
26+
import androidx.annotation.RequiresApi
27+
import com.duckduckgo.common.ui.view.toPx
28+
29+
@RequiresApi(33)
30+
class BottomBlurView @JvmOverloads constructor(
31+
context: Context,
32+
attrs: AttributeSet? = null,
33+
defStyleAttr: Int = 0,
34+
) : View(context, attrs, defStyleAttr) {
35+
36+
private val renderNode = RenderNode(null)
37+
private var targetView: View? = null
38+
private var maxBlurRadiusPx = MAX_BLUR_RADIUS_DP.toPx().toFloat()
39+
private val selfLocationOnScreen = IntArray(2)
40+
private val targetLocationOnScreen = IntArray(2)
41+
42+
private val blurShader = RuntimeShader(
43+
"""
44+
uniform shader inputShader;
45+
uniform vec2 size;
46+
uniform float maxBlurRadius;
47+
48+
const float TAU = 6.28318530718;
49+
const int DIRECTIONS = 16;
50+
const int QUALITY = 3;
51+
const float SAMPLES = float(DIRECTIONS * QUALITY + 1);
52+
53+
vec2 mirrorCoord(vec2 coord) {
54+
if (coord.x < 0.0) coord.x = -coord.x;
55+
else if (coord.x > size.x) coord.x = 2.0 * size.x - coord.x;
56+
57+
if (coord.y < 0.0) coord.y = -coord.y;
58+
else if (coord.y > size.y) coord.y = 2.0 * size.y - coord.y;
59+
60+
return coord;
61+
}
62+
63+
vec4 main(vec2 fragCoord) {
64+
vec2 uv = fragCoord.xy / size.xy;
65+
float radius = maxBlurRadius * uv.y;
66+
67+
vec4 pixel = inputShader.eval(mirrorCoord(fragCoord));
68+
69+
for (int i = 0; i < DIRECTIONS; i++) {
70+
float angle = (TAU * float(i)) / float(DIRECTIONS);
71+
vec2 dir = vec2(cos(angle), sin(angle));
72+
73+
for (int j = 1; j <= QUALITY; j++) {
74+
float frac = float(j) / float(QUALITY);
75+
vec2 offset = dir * radius * frac;
76+
vec2 sampleCoord = mirrorCoord(fragCoord + offset);
77+
pixel += inputShader.eval(sampleCoord);
78+
}
79+
}
80+
return pixel / SAMPLES;
81+
}
82+
""".trimIndent(),
83+
)
84+
85+
private val shaderEffect: RenderEffect
86+
get() = RenderEffect.createRuntimeShaderEffect(blurShader, "inputShader")
87+
88+
init {
89+
setLayerType(LAYER_TYPE_HARDWARE, null)
90+
background = null
91+
}
92+
93+
fun setTargetView(view: View) {
94+
targetView = view
95+
invalidate()
96+
}
97+
98+
override fun onDraw(canvas: Canvas) {
99+
val viewToBlur = targetView ?: return
100+
101+
val w = width
102+
val h = height
103+
if (w == 0 || h == 0) return
104+
105+
getLocationOnScreen(selfLocationOnScreen)
106+
viewToBlur.getLocationOnScreen(targetLocationOnScreen)
107+
val dx = (selfLocationOnScreen[0] - targetLocationOnScreen[0]).toFloat()
108+
val dy = (selfLocationOnScreen[1] - targetLocationOnScreen[1]).toFloat()
109+
110+
renderNode.setPosition(0, 0, w, h)
111+
renderNode.beginRecording().apply {
112+
translate(-dx, -dy)
113+
viewToBlur.draw(this)
114+
renderNode.endRecording()
115+
}
116+
117+
blurShader.setFloatUniform("size", w.toFloat(), h.toFloat())
118+
blurShader.setFloatUniform("maxBlurRadius", maxBlurRadiusPx)
119+
120+
renderNode.setRenderEffect(shaderEffect)
121+
canvas.drawRenderNode(renderNode)
122+
}
123+
124+
companion object {
125+
const val MAX_BLUR_RADIUS_DP = 6
126+
}
127+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (c) 2025 DuckDuckGo
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+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
18+
android:alpha="0.8"
19+
android:shape="rectangle">
20+
<gradient
21+
android:angle="270"
22+
android:endColor="?attr/daxColorBackground"
23+
android:startColor="@android:color/transparent" />
24+
</shape>

duckchat/duckchat-impl/src/main/res/layout/fragment_search_tab.xml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,28 @@
3030
app:layout_constraintStart_toStartOf="parent"
3131
app:layout_constraintEnd_toEndOf="parent"/>
3232

33+
<FrameLayout
34+
android:id="@+id/bottomFadeContainer"
35+
android:layout_width="match_parent"
36+
android:layout_height="80dp"
37+
android:elevation="8dp"
38+
android:foreground="@drawable/bottom_fade_overlay"
39+
app:layout_constraintBottom_toBottomOf="parent"
40+
app:layout_constraintStart_toStartOf="parent"
41+
app:layout_constraintEnd_toEndOf="parent"/>
42+
3343
<androidx.recyclerview.widget.RecyclerView
3444
android:id="@+id/autoCompleteSuggestionsList"
3545
android:layout_width="match_parent"
3646
android:layout_height="match_parent"
3747
android:background="?attr/daxColorBrowserOverlay"
3848
android:clipToPadding="false"
3949
android:elevation="4dp"
40-
app:layout_behavior="@string/appbar_scrolling_view_behavior"
50+
android:paddingBottom="64dp"
4151
tools:itemCount="3"
4252
tools:listitem="@layout/item_autocomplete_search_suggestion"
4353
tools:visibility="visible"
54+
app:layout_behavior="@string/appbar_scrolling_view_behavior"
4455
app:layout_constraintTop_toTopOf="parent"
4556
app:layout_constraintStart_toStartOf="parent"
4657
app:layout_constraintEnd_toEndOf="parent"/>

0 commit comments

Comments
 (0)