Touch and hold button to repeat action

Hi,

I have a requirement to develop a control similar to the Number Picker in Xamarin forms (where you have a + and - button to set a value). I need to show a + and - button and a label showing the current value. I need to be able to hold the button down to increment the number - not tap on the button over and over. I only really need it for Android.

I have created a custom control extending from View with a bindable property for CurrentValue. I have then created a custom renderer which returns a relative layout with two buttons and a label. I have called SetOnTouchListener and created a function to SetOnTouchListener which increments the number when either of the buttons are pressed. This all works fine when i press the button once -> the OnElementPropertyChanged event is fired and the UI updates. I have tried creating a timer on the ondown Motion and updating the "SelectedValue" of the control on an interval while the button remains down - but the OnElementPropertyChanged doesent fire in this case.

Is there something i am missing here? Below is what my OnTouchListener looks like. Is it because the timer cannot update the main thread? I put a breakpoint on my customControl binded property and i can see the setter for it is being called - but the value dosent change.

Please help!

public class MyTouchListener : Java.Lang.Object, Android.Views.View.IOnTouchListener
{
CustomControl myCustomControl;
CustomControlRenderer myCustomControlRenderer;
Android.Widget.RelativeLayout myRelativeLayout;

        public static readonly MyTouchListener Instance = new MyTouchListener();

        private int _normalInterval;
        private int _initialInterval;
        private Timer _timer;
        private bool _isLongPress = false;

        public MyTouchListener()
        {
            _initialInterval = 500;
            _normalInterval = 1000;
            _timer = new Timer(_initialInterval);
            _timer.Enabled = false;
            _timer.Elapsed += HandleTimerElapsed;
        }

        void HandleTimerElapsed(object sender, ElapsedEventArgs e)
        {
            _isLongPress = true;
            InvokeClickListener();
            _timer.Interval = _normalInterval;
        }

        private void InvokeInitialClickListener()
        {
            myCustomControl.SelectedValue++;
        }

        private void InvokeClickListener()
        {
            myCustomControl.SelectedValue++;
        }

        private void InvokeCancelListener()
        {

        }

        public bool OnTouch(Android.Views.View v, Android.Views.MotionEvent e)
        {
            var renderer = v.Tag as CustomControlRenderer;
            if (renderer == null)
                return false;

            myRelativeLayout= renderer.Control;
            myCustomControl= renderer.Element;

            if (v == renderer._upButton)
                switch (e.Action)
                {
                    case MotionEventActions.Down:
                        _timer.Enabled = true;
                        _timer.Start();
                        _isLongPress = false;
                        InvokeInitialClickListener();
                        return true;
                    case MotionEventActions.Up:
                    case MotionEventActions.Cancel:
                        _timer.Stop();
                        InvokeCancelListener();
                        return true;
                }
            return false;
        }
    }

Answers

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    Personally I'd start by making an AutoRepeatButton. That would be very reusable.
    Put all the logic for handling the timing of how fast to repeat, detecting the press down/up etc. in that control.
    Then have it raise a Pressed event every time it repeats internally.

    Then add two of those to your NumericUpDown and subscribe to the Pressed event like any other button.

    From the perspective of the NumericUpDown it wouldn't (and shouldn't) know if the user pressed the button 100 times, or if it auto repeated 100 times.

    Then you can also re-use the AutoRepeatButton in numerous cases and needed.

  • WilliamWattersonWilliamWatterson USUniversity ✭✭

    Thanks @ClintStLaurent for getting back to me. I have done as you suggested and extended the Android Button which calls uses "CallOnClick()" to trigger the click event on a timer. It works! Thank you very much for your suggestion.

    Here is my sample code for anyone else in a similar situation:

    CUSTOM BUTTON CLASS:
    class CustomButton : Button
    {

        public CustomButton(Context context) : base(context)
        {
            this.SetOnTouchListener(new MyTouchListener());
        }
    
        public class MyTouchListener : Java.Lang.Object, Android.Views.View.IOnTouchListener
        {
            private View v;
    
            private int _normalInterval;
            private int _initialInterval;
            private Timer _timer;
    
            public MyTouchListener()
            {
                _initialInterval = 500;
                _normalInterval = 100;
                _timer = new Timer(_initialInterval);
                _timer.Enabled = false;
                _timer.Elapsed += HandleTimerElapsed;
    
            }
    
            void HandleTimerElapsed(object sender, ElapsedEventArgs e)
            {
                InvokeClickListener();
                _timer.Interval = _normalInterval;
            }
    
            private void InvokeInitialClickListener()
            {
                v.CallOnClick();
            }
    
            private void InvokeClickListener()
            {
                v.CallOnClick();
            }
    
    
            public bool OnTouch(Android.Views.View pv, Android.Views.MotionEvent e)
            {
                v = pv;
    
                switch (e.Action)
                {
                    case MotionEventActions.Down:
                        _timer.Enabled = true;
                        _timer.Start();
                        InvokeInitialClickListener();
                        return true;
                    case MotionEventActions.Up:
                    case MotionEventActions.Cancel:
                        _timer.Stop();
                        return true;
                }
    
                return false;
            }
        }
    }
    

    THEN IN CUSTOM RENDERER CREATE TWO INSTANCES OF THIS BUTTON (one for up and one for down)

        btnIncrease = new CustomButton(this.Context);
                btnIncrease.TextSize = 32;
                btnIncrease.Background.SetAlpha(0);
                btnIncrease.Text = "+";
                btnIncrease.Click += btnIncrease_Click;
    
                btnDecrease = new CustomButton(this.Context);
                btnDecrease.Text = "-";
                btnDecrease.TextSize = 32;
                btnDecrease.Background.SetAlpha(0);
                btnDecrease.Click += btnDecrease_Click;
    

    IN CUSTOM RENDERER ALSO ADD IN CLICK EVENTS WHICH CALL THE BINDABLE COMMAND ON THE ORIGINAL CUSTOM CONTROL:

    private void btnIncrease_Click(object sender, EventArgs e)
        {
            Element.RaiseIncreaseCommand();
        }
    
        private void btnDecrease_Click(object sender, EventArgs e)
        {
            Element.RaiseDecreaseCommand();
        }
    
  • ChrisWoodardChrisWoodard USMember ✭✭

    @WilliamWatterson

    Would you be able to post the code for your custom control? I need some identical for a project I am working on.

    Ta.

  • WilliamWattersonWilliamWatterson USUniversity ✭✭

    Hi @ChrisWoodard - sorry only seeing this now. Basically it extends the Telerik gauge control for Android - to add in a + and - button which the user can tap and hold to increase the temp. Hopefully this helps

    Here is the code for my custom control.

    using System;
    using Android.Widget;
    using Xamarin.Forms;
    using Com.Telerik.Widget.Gauge;
    using Com.Telerik.Widget.Scales;
    using Com.Telerik.Widget.Indicators;
    using TestGauge1.Common.CustomRenderer;
    using TestGauge1.Droid.CustomRenderer;
    using Xamarin.Forms.Platform.Android;
    using System.Timers;
    using Android.Views;
    using Android.Content;
    using AView = Android.Views.View;
    using AColor = Android.Graphics.Color;

    [assembly: ExportRenderer(typeof(CustomGauge), typeof(CustomGuageRenderer))]
    namespace TestGauge1.Droid.CustomRenderer
    {
    ///


    /// This is the custom renderer for Android. It will return a relative layout containing:
    /// - the Telerik Android Gauge control
    /// - A label to show the temp (had to add my own label as the built in label did not work - possible bug in the beta version of the Telerik Android Gauge)
    /// - A + button to increase the temp (since touch is not enabled on the beta version of the Telerik Android gauge)
    /// - A - button to decrease the temp (since touch is not enabled on the beta version of the Telerik Android gauge)
    ///

    public class CustomGuageRenderer : ViewRenderer<CustomGauge, Android.Widget.RelativeLayout>
    {
    RadRadialGaugeView radRadialGaugeView;

        AColor green = AColor.ParseColor("#60009900");
        AColor orange = AColor.ParseColor("#60FFAE19");
        AColor red = AColor.ParseColor("#60ff2700");
    
        protected override void OnElementChanged(ElementChangedEventArgs<CustomGauge> e)
        {
            base.OnElementChanged(e);
            RepeatButton btnIncrease = null;
            RepeatButton btnDecrease = null;
    
            if (Control == null)
            {
                //create a new Telerik Android Gauge View
                radRadialGaugeView = new RadRadialGaugeView(this.Context);
    
                //Add a scale using the min and max values set on the original control
                GaugeRadialScale scale = new GaugeRadialScale(this.Context);
                scale.LineVisible = false;
                scale.SetRange(e.NewElement.MinValue, e.NewElement.MaxValue);
                scale.Radius = 0.9f;
                scale.TicksVisible = false;
                scale.LabelsVisible = false;
                radRadialGaugeView.AddScale(scale);
    
                //Add an indicator to the scale - this will be the "track" the ball/rectangle will follow - set to min and max of the original control
                GaugeRadialBarIndicator trackIndicator = new GaugeRadialBarIndicator(this.Context);
                trackIndicator.Minimum = e.NewElement.MinValue;
                trackIndicator.Maximum = e.NewElement.MaxValue;
                trackIndicator.FillColor = AColor.ParseColor("#90ffffff");
                trackIndicator.BarWidth = 0.05f;
                trackIndicator.Location = (float)0.825f;
                trackIndicator.Cap = GaugeBarIndicatorCapMode.Round;
                scale.AddIndicator(trackIndicator);
    
                //Add the "ball" indicator
                GaugeRadialBarIndicator ballIndicator = new GaugeRadialBarIndicator(this.Context);
                ballIndicator.Minimum = e.NewElement.SelectedValue - 0.15f;
                ballIndicator.Maximum = e.NewElement.SelectedValue + 0.15f;
                ballIndicator.Animated = false;
                ballIndicator.AnimationDuration = 200;
                ballIndicator.BarWidth = 0.2f;
                ballIndicator.Location = (float)0.9;
                ballIndicator.FillColor = AColor.White;
                ballIndicator.StrokeColor = AColor.Gray;
                ballIndicator.StrokeWidth = 1.0f;
                scale.AddIndicator(ballIndicator);
    
                //set the background colour depending on the selected value - call our Interpolate function
                //passing in an array of colours.  It will then calculate the rgb to set the background to depending how 
                //far along the SelectedValue is based on the min and max of the temp range
                e.NewElement.BgColour = Interpolate(new AColor[] { green, orange, red }, Element.MinValue, Element.MaxValue, Element.SelectedValue).ToColor();
    
                //Create a label to show the current selected temp value
                //Had to create our own label as the built in label did not appear in this beta version of the Telerik control
                TextView lblTemperatureValue = new TextView(this.Context);
                lblTemperatureValue.Text = String.Format("{0}˚C", e.NewElement.SelectedValue);
                lblTemperatureValue.SetTextSize(Android.Util.ComplexUnitType.Pt, 14);
    
                //Create our increase button (RepeatButton is a custom button control that works on a timer - see class below)
                btnIncrease = new RepeatButton(this.Context);
                btnIncrease.TextSize = 32;
                btnIncrease.Background.SetAlpha(0); //make the button transparent so the + and - appear with no background
                btnIncrease.Text = "+";
    
                btnDecrease = new RepeatButton(this.Context);
                btnDecrease.Text = "-";
                btnDecrease.TextSize = 40;
                btnDecrease.Background.SetAlpha(0); //make the button transparent so the + and - appear with no background
    
                //create a linear layout to hold the - button, the temp label and the + button
                LinearLayout linearLayout = new LinearLayout(this.Context);
                linearLayout.Orientation = Orientation.Horizontal;
                linearLayout.AddView(btnDecrease);
                linearLayout.AddView(lblTemperatureValue);
                linearLayout.AddView(btnIncrease);
    
                //align the linear layout to the centre of the relative layout we will be returning
                Android.Widget.RelativeLayout.LayoutParams rlp = new Android.Widget.RelativeLayout.LayoutParams(
                Android.Widget.RelativeLayout.LayoutParams.WrapContent,
                Android.Widget.RelativeLayout.LayoutParams.WrapContent);
                rlp.AddRule(LayoutRules.CenterInParent);
                linearLayout.LayoutParameters = rlp;
    
                Android.Widget.RelativeLayout layoutToReturn = new Android.Widget.RelativeLayout(this.Context);
                layoutToReturn.AddView(radRadialGaugeView);
                layoutToReturn.AddView(linearLayout);
    
                //replace the original control with our custom conrol (relative layout)
                SetNativeControl(layoutToReturn);
            }
    
            if (e.OldElement != null)
            {
                // Unsubscribe from the click events
                if (btnIncrease != null) btnIncrease.Click -= btnIncrease_Click;
                if (btnDecrease != null) btnDecrease.Click -= btnDecrease_Click;
            }
            if (e.NewElement != null)
            {
                // Subscribe to the click events
                if (btnIncrease != null) btnIncrease.Click += btnIncrease_Click;
                if (btnDecrease != null) btnDecrease.Click += btnDecrease_Click;
            }
        }
    
        /// <summary>
        /// This function will be called when the user presses the + button
        /// if the user presses and holds the + button - this will be called from the timer in the custom RepeatButton control
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btnIncrease_Click(object sender, EventArgs e)
        {            
            Element.RaiseIncreaseCommand();
        }
    
        /// <summary>
        ///  This function will be called when the user presses the - but if the user presses and holds 
        ///  the - button - this will be called from the timer in the custom RepeatButton control
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btnDecrease_Click(object sender, EventArgs e)
        {
            Element.RaiseDecreaseCommand();
        }
    
    
    
        /// <summary>
        /// This function will be called when a property on the original control is changed
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        protected override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            base.OnElementPropertyChanged(sender, e);
    
            if (Control == null || Element == null)
                return;
    
            //perform the actions if the selected value property is changed - we dont care about the other properties
            if (e.PropertyName == CustomGauge.SelectedValueProperty.PropertyName)
            {
                //get the gauge control from the relative layout
                Android.Widget.RelativeLayout relLayout = (Android.Widget.RelativeLayout)Control;
                var gauge = (RadRadialGaugeView)relLayout.GetChildAt(0);
    
                //set the new min and max values for the "ball"/square indicator - to make it look like its moving along 
                GaugeRadialBarIndicator ballIndicator = (GaugeRadialBarIndicator)gauge.Scales[0].Indicators[1];
                ballIndicator.Minimum = Element.SelectedValue - 0.15f;
                ballIndicator.Maximum = Element.SelectedValue + 0.15f;
    
                //get the new background colour based on the selected value
                Element.BgColour = Interpolate(new AColor[] { green, orange, red }, Element.MinValue, Element.MaxValue, Element.SelectedValue).ToColor();
    
                //update the temperature label to the new selected value
                var linearLayout = (LinearLayout)relLayout.GetChildAt(1);
                var child = (TextView)linearLayout.GetChildAt(1);
                child.Text = String.Format("{0}˚C", Element.SelectedValue.ToString());
            }
        }
    
    
        /// <summary>
        /// This function will take an array of colours and work out a new colour based on the selected value and the start and 
        /// end values.  Basically it will create a gradient of the colours and then work out which colour in the gradient it should
        /// return
        /// </summary>
        /// <param name="colors"></param>
        /// <param name="startValue"></param>
        /// <param name="endvalue"></param>
        /// <param name="selectedValue"></param>
        /// <returns></returns>
        public static AColor Interpolate(AColor[] colors, int startValue, int endvalue, double selectedValue)
        {
            double range = endvalue - startValue;
            double value = selectedValue - startValue;
    
            double percent = value / range;
    
            int left = (int)Math.Floor(percent * (colors.Length - 1));
            int right = (int)Math.Ceiling(percent * (colors.Length - 1));
            AColor colorLeft = colors[left];
            AColor colorRight = colors[right];
    
            double step = 1.0 / (colors.Length - 1);
            double percentRight = (percent - (left * step)) / step;
            double percentLeft = 1.0 - percentRight;
    
            AColor outputColor = new AColor();
    
            outputColor.R = (byte)(colorLeft.R * percentLeft + colorRight.R * percentRight);
            outputColor.G = (byte)(colorLeft.G * percentLeft + colorRight.G * percentRight);
            outputColor.B = (byte)(colorLeft.B * percentLeft + colorRight.B * percentRight);
            outputColor.A = (byte)(colorLeft.A);
    
            return outputColor;
        }
    }
    
    
    /// <summary>
    /// This class extends the original Android Button.  It assigns a touch listener which will call a click event on a timer - so the user 
    /// can tap and hold a button.  The click action is then repeated
    /// </summary>
    class RepeatButton : Android.Widget.Button
    {
    
        public RepeatButton(Context context) : base(context)
        {
            this.SetOnTouchListener(new MyTouchListener());
        }
    
        public class MyTouchListener : Java.Lang.Object, AView.IOnTouchListener
        {
            private AView v;
    
            private int _normalInterval; //when the user continues to hold the button down
            private int _initialInterval; //how long to wait when the user first taps the button before firing the event on a timer
            private Timer _timer;
    
            public MyTouchListener()
            {
                _initialInterval = 600;
                _normalInterval = 100;
                _timer = new Timer(_initialInterval);
                _timer.Enabled = false;
                _timer.Elapsed += HandleTimerElapsed;
            }
    
            void HandleTimerElapsed(object sender, ElapsedEventArgs e)
            {
                //call the click event again - the user is still holding the button down
                InvokeClickListener();
                _timer.Interval = _normalInterval;
            }
    
            private void InvokeInitialClickListener()
            {
                v.CallOnClick(); //call the normal click event
            }
    
            private void InvokeClickListener()
            {
                v.CallOnClick(); //call the normal click event
            }
    
    
            public bool OnTouch(AView pv, Android.Views.MotionEvent e)
            {
                v = pv;
    
                switch (e.Action)
                {
                    case MotionEventActions.Down: //user has pressed the button down
                        _timer.Enabled = true;
                        _timer.Start(); //start the timer 
                        InvokeInitialClickListener();
                        return true;
                    case MotionEventActions.Up:
                    case MotionEventActions.Cancel: //user has lifted their finger - stop the timer
                        _timer.Stop();
                        return true;
                }
    
                return false;
            }
        }
    }
    

    }

  • AyhamFakihaniAyhamFakihani USMember
    edited December 2018

    Was in a similar situation in Xamarin.Forms and ended up with a different approach using Behavior.

    /// <summary>
    /// Execute the bound command and invoke the event handler while the button is held down or at least once per click. 
    /// </summary>
    public class RepearButtonBehavior : Behavior<Button> {
        public static readonly BindableProperty CommandProperty = BindableProperty.Create(nameof(Command),
            typeof(ICommand), typeof(RepearButtonBehavior), default(ICommand));
    
        public static readonly BindableProperty CommandParameterProperty =
            BindableProperty.Create(nameof(CommandParameter), typeof(object), typeof(RepearButtonBehavior));
    
        public static readonly BindableProperty IntervalProperty = 
            BindableProperty.Create(nameof(IntervalProperty), typeof(int), typeof(RepearButtonBehavior), defaultValue: 150);
    
        /// <summary>
        /// Gets or sets the command parameter.
        /// </summary>
        public object CommandParameter
        {
            get => GetValue(CommandParameterProperty);
            set => SetValue(CommandParameterProperty, value);
        }
    
        /// <summary>
        /// Gets or sets the command.
        /// </summary>
        public ICommand Command
        {
            get => (ICommand)GetValue(CommandProperty);
            set => SetValue(CommandProperty, value);
        }
    
        /// <summary>
        /// Repetition interval
        /// </summary>
        public int Interval
        {
            get => (int)GetValue(IntervalProperty);
            set => SetValue(IntervalProperty, value);
        }
    
        /// <summary>
        /// Occurs when the associated button is long pressed.
        /// </summary>
        public event EventHandler ClickedOrHeldDown;
    
    
        private bool executedOnce;
        //timer to track long press
        private Timer _timer;
    
        //whether the button was released after press
        private volatile bool _isReleased;
    
    
        private readonly object _lockObject = new object();
    
        /// <summary>
        /// The button to which this behavoir is attached.
        /// </summary>
        public Button AssociatedButton { get; private set; }
    
    
        protected override void OnAttachedTo(Button button)
        {
            base.OnAttachedTo(button);
            // mapping the button press event 
            button.Pressed += Button_Pressed;
            // mapping the button release event 
            button.Released += Button_Released;
    
            // referencing the button itself (used to get hold of the binding context later in case it changes)
            AssociatedButton = button;
            // Keep an eye on the BindingContext of the button, and map it to the binding context of the behavoir when it changes. 
            button.BindingContextChanged += OnBindingContextChanged;
        }
    
        protected override void OnDetachingFrom(Button button)
        {
            base.OnDetachingFrom(button);
            button.Pressed -= Button_Pressed;
            button.Released -= Button_Released;
    
            // Stop watching the BindingContext of the button
            button.BindingContextChanged -= OnBindingContextChanged;
            // Remove the button referecne.
            AssociatedButton = null;
        }
    
        private void OnBindingContextChanged(object sender, EventArgs e)
        {
            base.OnBindingContextChanged();
            // Set the BindingContext of this behavoir to the button it's attached to. 
            BindingContext = AssociatedButton.BindingContext;
        }
    
        /// <summary>
        /// DeInitializes and disposes the timer.
        /// </summary>
        private void DeInitializeTimer()
        {
            lock (_lockObject)
            {
                // If the button is already disposed, do noting.
                if (_timer == null)
                    return;
    
    
                _timer.Change(Timeout.Infinite, Timeout.Infinite);
                _timer.Dispose();
                _timer = null;
    
    
                executedOnce = false;
                Debug.WriteLine("Timer disposed...");
            }
        }
    
        /// <summary>
        /// Initializes the timer.
        /// </summary>
        private void InitializeTimer()
        {
            lock (_lockObject)
                _timer = new Timer(Timer_Elapsed, null, Interval, Timeout.Infinite);
        }
    
    
        private void Button_Pressed(object sender, EventArgs e)
        {
            _isReleased = false;
    
            InitializeTimer();
        }
    
        private void Button_Released(object sender, EventArgs e)
        {
            _isReleased = true;
    
            // Check if the action has been executed at least once. 
            // if not, fire the action to preserve the tap behavoir. 
            // otherwise, dispose the timer. 
            if (!executedOnce)
                OnFireRepeatAction();
            else
                DeInitializeTimer();
    
        }
    
        protected virtual void OnFireRepeatAction()
        {
            // Set the exceute once flag to true. 
            executedOnce = true;
    
            // Invoke the handler
            ClickedOrHeldDown?.Invoke(this, EventArgs.Empty);
    
            // Invoke the command.
            if (Command != null && Command.CanExecute(CommandParameter))
                Command.Execute(CommandParameter);
        }
    
        public RepearButtonBehavior()
        {
            _isReleased = true;
    
        }
    
    
        private void Timer_Elapsed(object state)
        {
            // Button has already been release, dispose the timer.
            if (_isReleased)
            {
                DeInitializeTimer();
                return;
            }
            // Button is still pressed
            // Fire the action.
            Device.BeginInvokeOnMainThread(OnFireRepeatAction);
            // Reset the timer.
            _timer.Change(Interval, Timeout.Infinite);
        }
    }
    
    

    And in your XAML file

    <Button Text="Hold me and i'll repeat or at least i'll do it once">
    
        <!-- Remember the binding context of your command. And the xml namespace declaration -->
        <Button.Behaviors>
            <behaviors:RepearButtonBehavior  Command="{Binding YourCommand}" />
        </Button.Behaviors>
    </Button>
    
    

    Inspired by:

    • stackoverflow.com/a/51247520
    • forums.xamarin.com/discussion/88187/custom-behavior-command-not-binding
Sign In or Register to comment.