Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f4cf9b4
Flatten View hierarchy in attachment keyboard
Mar 28, 2025
27128bd
Debug shit
Apr 11, 2025
f1a5e3e
Merge branch 'main' into smooth-attach-menu
Jun 23, 2025
fb46cd9
Use LinearLayout for attachment keyboard buttons
Jun 25, 2025
923cee8
Fade out the fake keyboard when closing it
Jun 28, 2025
4489baa
Fix error in merge conflict
Jun 29, 2025
5874572
Don't let the keyboard guideline animators step on each other
Jun 29, 2025
833c09a
Speed up the animations now that we're done debugging
Jun 29, 2025
51cf1f4
Use new duration for animating the fake keyboard
Jun 29, 2025
26b9c8b
Clean up log statements
Jun 29, 2025
c363364
Add fancy animated effect to the attach button
Jun 30, 2025
0a627c7
Never mind it didn't do what I thought it would
Jun 30, 2025
638855a
Animate the attach button when the attachment keyboard is opened/closed
Jun 30, 2025
90b2229
Remove files used solely for debugging purposes
Jun 30, 2025
a488a5b
Merge branch 'main' into smooth-attach-menu
Sep 25, 2025
e64c998
Make Kotlin things more idiomatic
Sep 25, 2025
be62462
Fix recentering algorithm
Sep 27, 2025
b346a21
Shorten fake keyboard show/hide animation to 200ms
Sep 27, 2025
407fe18
Merge branch 'main' into smooth-attach-menu
Sep 27, 2025
98c463e
Import extensions for more idiomatic Kotlin
Sep 27, 2025
24465d2
Hopefully fix things
Sep 28, 2025
95b61b0
Run ./gradlew format, remove dev comments, lower debug level for logs
Sep 29, 2025
60c2a8e
Merge branch 'main' into smooth-attach-menu
Nov 26, 2025
9528f51
Fix weird attachment button recenterer regression
Nov 27, 2025
8913eaa
Shorten fake keyboard hide duration to 160ms
Nov 27, 2025
f0d0750
Fix the fix to the recentering algorithm
Nov 29, 2025
347601e
Fix the fix to the fix to the recentering algorithm
Dec 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton

class ButtonStripItemView @JvmOverloads constructor(
context: Context,
Expand Down Expand Up @@ -42,4 +43,10 @@ class ButtonStripItemView @JvmOverloads constructor(
fun setOnIconClickedListener(onIconClickedListener: (() -> Unit)?) {
iconView.setOnClickListener { onIconClickedListener?.invoke() }
}

/** Fills the icon/label from the given button enum */
fun fillFromButton(button: AttachmentKeyboardButton) {
iconView.setImageResource(button.iconRes)
labelView.setText(button.titleRes)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.util.AttributeSet
import android.widget.EditText
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil

Expand All @@ -23,6 +24,10 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
defStyleAttr: Int = 0
) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) {

companion object {
private val TAG = Log.tag(InputAwareConstraintLayout::class)
}

private var inputId: Int? = null
private var input: Fragment? = null
private var wasKeyboardVisibleBeforeToggle: Boolean = false
Expand Down Expand Up @@ -124,11 +129,13 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
}

private fun hideInput(resetKeyboardGuideline: Boolean) {
Log.d(TAG, "hideInput: $input, resetKeyboardGuideline=$resetKeyboardGuideline")
val inputHidden = input != null
input?.let {
(input as? InputFragment)?.hide()
fragmentManager
.beginTransaction()
.setCustomAnimations(0, R.anim.fade_out_slowly)
.remove(it)
.commit()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ public class InputPanel extends ConstraintLayout
private QuoteView quoteView;
private LinkPreviewView linkPreview;
private EmojiToggle mediaKeyboard;
private ImageButton inlineAttachButton;
private ImageButton mainAttachButton;
private ComposeText composeText;
private ImageButton quickCameraToggle;
private ImageButton quickAudioToggle;
Expand All @@ -112,6 +114,7 @@ public class InputPanel extends ConstraintLayout
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator attachButtonAnimator;
private ValueAnimator quoteAnimator;
private ValueAnimator editMessageAnimator;
private VoiceNoteDraftView voiceNoteDraftView;
Expand Down Expand Up @@ -151,6 +154,8 @@ public void onFinishInflate() {
this.quoteView = findViewById(R.id.quote_view);
this.linkPreview = findViewById(R.id.link_preview);
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.inlineAttachButton = findViewById(R.id.inline_attachment_button);
this.mainAttachButton = findViewById(R.id.attach_button);
this.composeText = findViewById(R.id.embedded_text_editor);
this.composeTextContainer = findViewById(R.id.embedded_text_editor_container);
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
Expand Down Expand Up @@ -380,6 +385,36 @@ public MediaKeyboard.MediaKeyboardListener getMediaKeyboardListener() {
return mediaKeyboard;
}

/** Sets the attach buttons to a + sign. */
public void rotateAttachButtonsToOpen() {
if (attachButtonAnimator != null && attachButtonAnimator.isRunning()) {
attachButtonAnimator.end();
}
attachButtonAnimator = createAttachmentButtonRotationAnimator(45, 0);
attachButtonAnimator.start();
}

/** Sets the attach buttons to a x sign. */
public void rotateAttachButtonsToClose() {
if (attachButtonAnimator != null && attachButtonAnimator.isRunning()) {
attachButtonAnimator.end();
}
attachButtonAnimator = createAttachmentButtonRotationAnimator(0, 45);
attachButtonAnimator.start();
}

private ValueAnimator createAttachmentButtonRotationAnimator(float start, float end) {
ValueAnimator animator = ValueAnimator.ofFloat(start, end).setDuration(getResources().getInteger(R.integer.fake_keyboard_hide_duration));
animator.addUpdateListener(
animation -> {
float degrees = (float) animation.getAnimatedValue();
inlineAttachButton.setRotation(degrees);
mainAttachButton.setRotation(degrees);
}
);
return animator;
}

public void setWallpaperEnabled(boolean enabled) {
final int iconTint;
final int textColor;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.components

import android.animation.ValueAnimator
import android.content.Context
import android.content.res.Configuration
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Guideline
import androidx.core.content.withStyledAttributes
Expand Down Expand Up @@ -58,6 +60,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
private val keyboardAnimator = KeyboardInsetAnimator()
private var overridingKeyboard: Boolean = false
private var otherKeyboardAnimator: ValueAnimator? = null
private var previousKeyboardHeight: Int = 0
private var previousStatusBarInset: Int = 0

Expand Down Expand Up @@ -157,14 +160,18 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
Log.d(TAG, "applyInsets (keyboardInsets): setting guideline=${keyboardInsets.bottom}")
} else {
Log.d(TAG, "applyInsets (keyboardInsets/else): ${keyboardInsets.bottom}")
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
}
}
} else if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
Log.d(TAG, "applyInsets (navigationBar): setting guideline=${navigationBar}")
keyboardGuideline?.setGuidelineEnd(navigationBar)
} else {
Log.d(TAG, "applyInsets (navigationBar/else): ${navigationBar}")
keyboardAnimator.endingGuidelineEnd = navigationBar
}
}
Expand All @@ -184,7 +191,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(

protected fun overrideKeyboardGuidelineWithPreviousHeight() {
overridingKeyboard = true
keyboardGuideline?.setGuidelineEnd(getKeyboardHeight())
animateKeyboardGuidelineTo(getKeyboardHeight())
}

protected fun clearKeyboardGuidelineOverride() {
Expand All @@ -193,8 +200,30 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(

protected fun resetKeyboardGuideline() {
clearKeyboardGuidelineOverride()
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
keyboardAnimator.endingGuidelineEnd = navigationBarGuideline.guidelineEnd
animateKeyboardGuidelineTo(navigationBarGuideline.guidelineEnd)
}

private fun animateKeyboardGuidelineTo(target: Int) {
if (otherKeyboardAnimator?.isRunning == true) {
otherKeyboardAnimator?.end()
otherKeyboardAnimator = null
}
if (keyboardAnimator.animating) {
// If Android is animating the keyboard in/out, forgo our own animation.
keyboardGuideline?.setGuidelineEnd(target)
return
}
Log.d(TAG, "Manually animating keyboard guideline: ${keyboardGuideline.guidelineEnd} -> $target")
otherKeyboardAnimator = ValueAnimator.ofInt(keyboardGuideline.guidelineEnd, target).apply {
duration = resources.getInteger(R.integer.fake_keyboard_hide_duration).toLong()
addUpdateListener { animation ->
(animation.animatedValue as? Int)?.let { currentValue ->
keyboardGuideline?.setGuidelineEnd(currentValue)
}
}
start()
}
}

private fun getKeyboardHeight(): Int {
Expand Down Expand Up @@ -258,8 +287,14 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
return
}

if (otherKeyboardAnimator?.isRunning == true) {
// Terminate other keyboard guideline animations as to not interfere with this one
otherKeyboardAnimator?.end()
otherKeyboardAnimator = null
}
animating = true
startingGuidelineEnd = keyboardGuideline.guidelineEnd
Log.d(TAG, "KeyboardInsetAnimator animating from $startingGuidelineEnd")
}

override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
Expand Down Expand Up @@ -293,6 +328,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}

keyboardGuideline?.setGuidelineEnd(endingGuidelineEnd)
Log.d(TAG, "KeyboardInsetAnimator animated to $endingGuidelineEnd")
animating = false
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,75 @@
package org.thoughtcrime.securesms.conversation

import androidx.core.view.doOnNextLayout
import androidx.recyclerview.widget.RecyclerView
import android.view.View
import android.view.View.OnLayoutChangeListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log

/**
* Adds necessary padding to each side of the given RecyclerView in order to ensure that
* if all buttons can fit in the visible real-estate on screen, they are centered.
* Adds necessary padding to each side of the given ViewGroup in order to ensure that
* if all buttons can fit in the visible real-estate as defined by the wrapper view,
* then they are centered. However if there are too many buttons to fit on the screen,
* then put a basic amount of padding on each side so that it looks nice when scrolling
* to either end.
*/
class AttachmentButtonCenterHelper(private val recyclerView: RecyclerView) : RecyclerView.AdapterDataObserver() {

private val itemWidth: Float = DimensionUnit.DP.toPixels(88f)
private val defaultPadding: Float = DimensionUnit.DP.toPixels(16f)

override fun onChanged() {
val itemCount = recyclerView.adapter?.itemCount ?: return
val requiredSpace = itemWidth * itemCount

recyclerView.doOnNextLayout {
if (it.measuredWidth >= requiredSpace) {
val extraSpace = it.measuredWidth - requiredSpace
val availablePadding = extraSpace / 2f
it.post {
it.setPadding(availablePadding.toInt(), it.paddingTop, availablePadding.toInt(), it.paddingBottom)
}
} else {
it.setPadding(defaultPadding.toInt(), it.paddingTop, defaultPadding.toInt(), it.paddingBottom)
class AttachmentButtonCenterHelper(val buttonHolder: View, val wrapper: View) {

companion object {
val TAG = Log.tag(AttachmentButtonCenterHelper::class)
private val DEFAULT_PADDING = DimensionUnit.DP.toPixels(16f).toInt()
}

/** The wrapper width is the maximum size of the button holder before scrollbars appear. */
private val wrapperWidthObservable: PublishSubject<Int> = PublishSubject.create()
private val emitNewWrapperWidth = OnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ ->
if (oldRight - oldLeft == right - left)
return@OnLayoutChangeListener
wrapperWidthObservable.onNext(right - left)
}

/** The "core width" of the button holder is the size of its contents. */
private val coreWidthObservable: PublishSubject<Int> = PublishSubject.create()
private val emitNewCoreWidth = OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
val newCoreWidth = view.run { width - (paddingLeft + paddingRight) }
coreWidthObservable.onNext(newCoreWidth)
}

private var listener: Disposable? = null

fun attach() {
wrapper.addOnLayoutChangeListener(emitNewWrapperWidth)
buttonHolder.addOnLayoutChangeListener(emitNewCoreWidth)

listener?.dispose()
listener = Observable.combineLatest(wrapperWidthObservable, coreWidthObservable, ::Pair)
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { widths ->
val wrapperWidth = widths.first
val coreWidth = widths.second
Log.d(TAG, "wrapperWidth: $wrapperWidth, coreWidth: $coreWidth")
recenter(coreWidth, wrapperWidth)
}
}
}

fun detach() {
wrapper.removeOnLayoutChangeListener(emitNewWrapperWidth)
buttonHolder.removeOnLayoutChangeListener(emitNewCoreWidth)
listener?.dispose()
listener = null
}

fun recenter(buttonHolderCoreWidth: Int, wrapperWidth: Int) {
val extraSpace = wrapperWidth - buttonHolderCoreWidth
val horizontalPadding = if (extraSpace >= 0)
(extraSpace / 2f).toInt()
else
DEFAULT_PADDING
Log.d(TAG, "will add $horizontalPadding px on either side")
buttonHolder.apply { setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import com.bumptech.glide.Glide;
Expand Down Expand Up @@ -44,9 +43,9 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.

private View container;
private AttachmentKeyboardMediaAdapter mediaAdapter;
private AttachmentKeyboardButtonAdapter buttonAdapter;
private Callback callback;

private AttachmentKeyboardButtonList buttonList;
private RecyclerView mediaList;
private TextView permissionText;
private MaterialButton permissionButton;
Expand All @@ -71,16 +70,14 @@ private void init(@NonNull Context context) {
this.permissionButton = findViewById(R.id.attachment_keyboard_permission_button);
this.manageButton = findViewById(R.id.attachment_keyboard_manage_button);

RecyclerView buttonList = findViewById(R.id.attachment_keyboard_button_list);
buttonList.setItemAnimator(null);

mediaAdapter = new AttachmentKeyboardMediaAdapter(Glide.with(this), media -> {
if (callback != null) {
callback.onAttachmentMediaClicked(media);
}
});

buttonAdapter = new AttachmentKeyboardButtonAdapter(button -> {
buttonList = findViewById(R.id.attachment_keyboard_button_list);
buttonList.setOnButtonClicked(button -> {
if (callback != null) {
callback.onAttachmentSelectorClicked(button);
}
Expand All @@ -97,14 +94,10 @@ private void init(@NonNull Context context) {

mediaList.setAdapter(mediaAdapter);
mediaList.addOnScrollListener(new ScrollListener(manageButton.getMeasuredWidth()));
buttonList.setAdapter(buttonAdapter);

buttonAdapter.registerAdapterDataObserver(new AttachmentButtonCenterHelper(buttonList));

mediaList.setLayoutManager(new GridLayoutManager(context, 1, GridLayoutManager.HORIZONTAL, false));
buttonList.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false));

buttonAdapter.setButtons(DEFAULT_BUTTONS);
buttonList.setButtons(DEFAULT_BUTTONS);
}

public void setCallback(@NonNull Callback callback) {
Expand All @@ -113,9 +106,9 @@ public void setCallback(@NonNull Callback callback) {

public void filterAttachmentKeyboardButtons(@Nullable Predicate<AttachmentKeyboardButton> buttonPredicate) {
if (buttonPredicate == null) {
buttonAdapter.setButtons(DEFAULT_BUTTONS);
buttonList.setButtons(DEFAULT_BUTTONS);
} else {
buttonAdapter.setButtons(DEFAULT_BUTTONS.stream().filter(buttonPredicate).collect(Collectors.toList()));
buttonList.setButtons(DEFAULT_BUTTONS.stream().filter(buttonPredicate).collect(Collectors.toList()));
}
}

Expand Down Expand Up @@ -165,7 +158,6 @@ public void setWallpaperEnabled(boolean wallpaperEnabled) {
} else {
container.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.signal_background_primary));
}
buttonAdapter.setWallpaperEnabled(wallpaperEnabled);
}

@Override
Expand Down Expand Up @@ -264,4 +256,9 @@ public interface Callback {
void onAttachmentPermissionsRequested();
void onDisplayMoreContextMenu(View v, boolean showAbove, boolean showAtStart);
}

public interface AttachmentKeyboardListener {
void onAttachmentKeyboardShown();
void onAttachmentKeyboardHidden();
}
}
Loading