MayNotBe
MayNotBe

Reputation: 2140

MotionLayout: MotionScene OnClick overrides setOnClickListener

I'm just starting to play with MotionLayout. I have defined an activity layout using MotionLayout that uses a MotionScene to hide and show a view.

The MotionScene transition looks like this:

<Transition
    app:constraintSetStart="@id/collapsed"
    app:constraintSetEnd="@id/expanded">

    <OnClick app:target="@id/nextButton"  />

</Transition>

Troublie is, nothing happens when I programmatically add a ClickListener to the button:

nextButton.setOnClickListener {
        //do some stuff
    }

This listener is completely ignored but the transition (view expanding/collapsing) is triggered with every click. I have seen where someone extends MotionLayout to handle click events but it seems like there might be an easier way to add another click listener for the button.

Question 1: Is there a way to add a ClickListener to the target of an OnClick in a MotionLayout transition?

Question 2: Is there a way to make the transition a one time only event? The desired result is that if the view is collapsed when the button is clicked, then the view expands, but if it's already expanded then it stays expanded.

Lastly, I'm using the namespace "http://schemas.android.com/apk/res-auto" and the docs clearly state that target and mode are attributes for OnClick. But the project won't compile when I use mode because it can't be found in that namespace.

Question 3: Am I using the correct namespace?

Upvotes: 25

Views: 15472

Answers (9)

Muchlish Choeruddin
Muchlish Choeruddin

Reputation: 33

I got the problem yesterday and just now it solved and all I do is just add a View tag inside of the MotionLayout tag, then give it an onClick attribute.

here is the preview

<androidx.constraintlayout.motion.widget.MotionLayout
    android:id="@+id/buttonMotion"
    android:layout_width="70dp"
    android:layout_height="70dp"
    android:layout_gravity="center"
    app:layoutDescription="@xml/circle_to_square">
    <androidx.constraintlayout.utils.widget.ImageFilterView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/puthPic"
        android:scaleType="centerCrop"
        android:src="@drawable/red"
        android:onClick="toggleBroadcasting"
        />
    <View
        android:id="@+id/puthPicView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:onClick="toggleBroadcasting"/>
</androidx.constraintlayout.motion.widget.MotionLayout>

You can see my gist here for further information.

Upvotes: 0

wowandy
wowandy

Reputation: 1302

The simplest solution, in my opinion, is the MotionLayout extension

You can create your own custom MotionLayout and process touches with some threshold. For example:

import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.motion.widget.MotionLayout;

public class CustomMotionLayout extends MotionLayout {

    private final static int CLICK_ACTION_THRESHOLD = 5;
    private float startX;
    private float startY;

    public CustomMotionLayout(@NonNull Context context) {
        super(context);
    }

    public CustomMotionLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomMotionLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                startX = event.getX();
                startY = event.getY();
                break;
            case MotionEvent.ACTION_UP:
                float endX = event.getX();
                float endY = event.getY();
                if (isAClick(startX, endX, startY, endY)) {
                    // here we pass the MotionEvent
                    // to the next touch listener, bypassing 
                    // the MotionLayout touch event handler
                    return false;
                }
                break;
        }
        return super.onTouchEvent(event);
    }

    private boolean isAClick(float startX, float endX, float startY, float endY) {
        float differenceX = Math.abs(startX - endX);
        float differenceY = Math.abs(startY - endY);
        return !(differenceX > CLICK_ACTION_THRESHOLD || differenceY > CLICK_ACTION_THRESHOLD);
    }

}

You can then handle the click on the item like this:

binding.carousel.setAdapter(new Carousel.Adapter() {
    @Override
    public int count() {
        return images.length;
    }

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public void populate(View view, int index) {
        if (view instanceof ImageView) {
            ImageView imageView = (ImageView) view;
            imageView.setImageResource(images[index]);
            imageView.setOnTouchListener(new View.OnTouchListener() {
                @Override
                public boolean onTouch(View view, MotionEvent motionEvent) {
                    Log.d("CLICK", "click"); // <-- click
                    return false;
                }
            });
        }
    }

    @Override
    public void onNewItem(int index) {}
});

It's not a perfect implementation, just an example, but it doesn't interfere with the animated carousel and handles clicks

Upvotes: 0

Here is simple solution:

Just add this fun:

 @SuppressLint("ClickableViewAccessibility")
fun View.setOnClick(clickEvent: () -> Unit) {
    this.setOnTouchListener { _, event ->
        if (event.action == MotionEvent.ACTION_UP) {
            clickEvent.invoke()
        }
        false
    }
}

This is how you use it:

nextButton.setOnClick {
        //Do something 
}

Upvotes: 0

hoford
hoford

Reputation: 5323

In general if you need a callback you probably will want to control the animation yourself. So if you are adding an onClick you should call the transition yourself.

public void onClick(View v) {
   ((MotionLayout)v.getParent()).transitionToEnd());
   // you can decide all the actions and conditionals.
 }

The intent was is useful what the developer does not care. hide/reveal of ui elements etc. or for testing before you get to wiring up the callbacks.

Upvotes: 1

You can implement MotionLayout.TransitionListener to handler event when transition.

public class LoginActivity extends AppCompatActivity implements MotionLayout.TransitionListener {
private static final String TAG = "LoginActivity";
private FirebaseAuth mAuth;
private LoginLayoutBinding binding;

@SuppressLint("ClickableViewAccessibility")
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    binding = LoginLayoutBinding.inflate(getLayoutInflater());
    setContentView(binding.getRoot());

    // initialize the FirebaseAuth instance.
    mAuth = FirebaseAuth.getInstance();
    binding.getRoot().addTransitionListener(this);
}


@Override
public void onStart() {
    super.onStart();
    // Check if user is signed in (non-null) and update UI accordingly.
    FirebaseUser currentUser = mAuth.getCurrentUser();
    updateUI(currentUser);
}

private void updateUI(FirebaseUser currentUser) {
    hideProgressBar();
    if (currentUser != null) {
        Intent intent = new Intent(LoginActivity.this, MainActivity.class);
        startActivity(intent);
        finish();
    }
}

private void hideProgressBar() {
    binding.progressBar2.setVisibility(View.GONE);
}

private void createAccount(String email, String password) {
    mAuth.createUserWithEmailAndPassword(email, password)
            .addOnCompleteListener(this, new OnCompleteListener<AuthResult>() {
                @Override
                public void onComplete(@NonNull Task<AuthResult> task) {
                    if (task.isSuccessful()) {
                        // Sign in success, update UI with the signed-in user's information
                        Log.d(TAG, "createUserWithEmail:success");
                        FirebaseUser user = mAuth.getCurrentUser();
                        updateUI(user);
                    } else {
                        // If sign in fails, display a message to the user.
                        Log.w(TAG, "createUserWithEmail:failure", task.getException());
                        Toast.makeText(LoginActivity.this, "Authentication failed.",
                                Toast.LENGTH_SHORT).show();
                        updateUI(null);
                    }
                }
            });
}

private void signIn(String email, String password) {
    mAuth.signInWithEmailAndPassword(email, password)
            .addOnCompleteListener(this, new OnCompleteListener<AuthResult>() {
                @Override
                public void onComplete(@NonNull Task<AuthResult> task) {
                    if (task.isSuccessful()) {
                        // Sign in success, update UI with the signed-in user's information
                        Log.d(TAG, "signInWithEmail:success");
                        FirebaseUser user = mAuth.getCurrentUser();
                        updateUI(user);
                    } else {
                        // If sign in fails, display a message to the user.
                        Log.w(TAG, "signInWithEmail:failure", task.getException());
                        Toast.makeText(LoginActivity.this, "Authentication failed.",
                                Toast.LENGTH_SHORT).show();
                        updateUI(null);
                    }
                }
            });
}


@Override
public void onTransitionStarted(MotionLayout motionLayout, int startId, int endId) {

}

@Override
public void onTransitionChange(MotionLayout motionLayout, int startId, int endId, float progress) {

}

@Override
public void onTransitionCompleted(MotionLayout motionLayout, int currentId) {
    if (currentId==R.id.end){
        binding.btnLogin.setText(R.string.sign_up);
        binding.textView3.setEnabled(false);
        binding.textView2.setEnabled(true);
    }else {
        binding.btnLogin.setText(R.string.login);
        binding.textView2.setEnabled(false);
        binding.textView3.setEnabled(true);
    }

}

@Override
public void onTransitionTrigger(MotionLayout motionLayout, int triggerId, boolean positive, float progress) {

}

}

Upvotes: 0

I just used this hack: click is handled programmatically, but it triggers the hidden view, on which <OnClick> is registered in MotionScene:

actualVisibleView.setOnClickListener {
            doSomeLogic()
            hiddenView.performClick()
        }

And in MotionScene:

<Transition
        android:id="@+id/hackedTransitionThanksToGoogle"
        motion:constraintSetEnd="@layout/expanded"
        motion:constraintSetStart="@layout/closed"
        motion:duration="300"
        motion:motionInterpolator="linear">

        <OnClick
            motion:clickAction="transitionToEnd"
            motion:targetId="@+id/hiddenView" />
</Transition>

Upvotes: 3

kjanderson2
kjanderson2

Reputation: 1359

You can also just handle the click programmatically from the beginning by removing

 <OnClick app:target="@id/nextButton"  />

altogether. Also it is easy to see whether or not your view is expanded by checking the progress of your transition. So you can programmatically handle it in your java/kotlin file with

yourButton.setOnClickListener {
    if (yourMotionLayoutId.progress == 0.0)
        yourMotionLayoutId.transitionToEnd
}

This way, it will check if the transition is in the state where it has not occurred (progress will be 0.0) and transition, otherwise, it will do nothing.

Upvotes: 10

Erick
Erick

Reputation: 372

I found a cleaner and more correct way to do it, you can do this .... OnClick directly from view ..

Note: It does not work with: <OnSwipe/> only <OnClick/>

PD. I'm sorry, I'm from Mexico and I'm using the translator

<androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/play_pause_button_collapsed"
        android:layout_width="30dp"
        android:layout_height="50dp"
        app:srcCompat="@drawable/ic_play_arrow_black_48dp"
        android:layout_marginTop="25dp"
        android:elevation="2dp"
        android:alpha="0"

        android:onClick="handleAction"

        tools:ignore="ContentDescription" />



fun handleAction(view: View) { 
   //handle click
}

Upvotes: 2

jaredg
jaredg

Reputation: 181

  1. Not that I can find.
  2. I found success using the clickAction attribute with the "transitionToEnd" value. This will make it so that the motionlayout cannot go back to the startConstraintSet.
<OnClick
      motion:targetId="@+id/rateUsButton"
      motion:clickAction="transitionToEnd"/>
  1. That is the namespace I am using, as well as the namespaced used in examples I've seen.

Just ran into the same issue today. I was able to intercept the click by using setOnTouchListener instead of setOnClickListener in my code.

rateUsButton.setOnTouchListener { _, event ->
    if (event.action == MotionEvent.ACTION_UP) {
        // handle the click
    }
    false
}

I know this solution isn't the best but I didn't find another option. Returning false means the touch was not handled here and thus will be handled by the motion layout.

Upvotes: 15

Related Questions