ListView-Any way to MVVM bind to ScrollTo?

ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭
edited February 2016 in Xamarin.Forms

I'd like for my ListView to scroll to the last item added to the ObservableCollection<> that is its .ItemSource, when a new item is added.
I can't seem to find any way to do that. ScrollTo() is a method, not a property, so there is no way to bind that to a property in the ViewModel.
Is there a way to make the ListView auto-scroll to the SelectedItem; which can be set through binding? Its kinda silly for the ViewModel to set a new item as selected, but the ListView doesn't scroll to it/bring it into view. Something like this would be nice: EIther to binding to an item to bring into view or a bool to autoscroll to selected.

            <ListView x:Name="imagesListView"
                      ItemsSource="{Binding PendingScans}"
                      SelectedItem="{Binding SelectedPendingImagePath,
                                     Mode=TwoWay}"
                      ScrollToItem="{Binding SelectedPendingImagePath}"
                      ScrollToSelected = "True"
                      Grid.Row="1"
                      Grid.Column="1"></ListView>

Best Answer

Answers

  • JulienRosenJulienRosen CAMember ✭✭✭✭
    edited February 2016

    you'll have to tie back to the view somehow. scrolling is a view only concept, there's no vm analogue. just extend listview to monitor for changes to the selected item and scroll to it.

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    Inheriting from ListView to make a custom control is an option I've considered. It just isn't my first choice if there is a way to make the OEM control behave as it should. I had hoped I just overlooked an option or property for it.

  • JulienRosenJulienRosen CAMember ✭✭✭✭
    edited February 2016

    What issues do you see with a custom ListView? it is minimal, easy code. I don't think having scroll behavior accessible from the VM would be "behaving as it should". I know in WPF, the same scrolling "issues" exist.

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭
    edited February 2016

    Sure its easy code. It just means that every developer on the team has to know to use the custom version of the control instead of the OEM control. That's not something I like. It means a year from now and 20 controls later you have to maintain this mental list of... "I can use the OEM button, but the custom DateView, the OEM ComboBox is fine, {oh} but now that we want this behavior we have to replace all of the OEM ListViews throughout the application with the custom version. {...}

    I try to look at maintainability and find ways around complicating my code - especially when in a multi-developer environment. In WPF I would add a 'behavior' that applies to all ListViews on an application-wide scope. Then nothing needs to be done on an individual control-by-control basis. But behaviors don't exist in Xamarin.Forms: DependencyObject and IBehavior are strictly WPF concepts.

    If the ScrollTo object were a bindable property then it could also be rolled up in a Style, keeping consistent look/feel/behavior throughout the application without having to touch every individual UI object.

  • JulienRosenJulienRosen CAMember ✭✭✭✭
    edited February 2016

    @ClintStLaurent I would extend the ListView and then use it everywhere, all the time, with all developers. There is no need to have to choose between one or the other. If you want to be able to toggle the functionality, create a DP that enables/disables the scrolling.

    Behaviors certainly sounds like a nice way to go though, I don't have much experience with them. If you get that working I would love to see some code.

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    Thanks Joe for letting me know there is a Behaviors class to work with. You're not wrong that I'm less than 3 months working in Xamarin.Forms. I had to make a couple adjustments to your code: ItemsSource not ItemSource, and Last() doesn't exist by default, so I had to throw the source to an ObservableCollection<> first. Then I had to cast the i.Item to a string so the compare was string to string not string to object.

                imagesListView.ItemAppearing += (sender, e) =>
                {
                    if ((string)e.Item == (imagesListView.ItemsSource as ObservableCollection<string>).Last())
                    {
                        imagesListView.ScrollTo(e.Item, ScrollToPosition.MakeVisible, false);
                    }
                };
    

    Sadly it made no difference to the behavior of the ListView. New items get added to the collection in the ViewModel, the last item gets selected. But the ListView never scrolls to bring that last item in to view. If the user (me) scrolls the listview with their finger the last item eventually comes into view and it is selected.

    Which got me thinking that .ItemAppearing may not have been the right event in this case. Since the .ItemSelected is already bound in the XAML it made sense I could use the .ItemSelected event instead. After a bit of tweeking I eventually came to this code that is working for me.

                imagesListView.ItemSelected += (sender, e) =>
                {
                    try
                    {
                        imagesListView.ScrollTo(e.SelectedItem, ScrollToPosition.MakeVisible, false);
                        //Try avoids IndexOutOfRange at first launch
                    }
                    catch (Exception)
                    {
    
                    } 
                };
    

    I don't have to be thrilled about having code in the C# code-behind. In this case its fast, simple, readable and obvious when other team developers see it. I wouldn't have thought to go this route without your input. Thank you.

    I'll definitely be looking at Xamarin's implementation of Behaviors when the pressure for this Proof of Concept app let's up.

  • JulienRosenJulienRosen CAMember ✭✭✭✭

    So you are adding that code snippet to any view that has a ListView and referencing it by name?

    That has got to be worse then an extension to ListView no?

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭
    edited February 2016

    Right now its a one-off. So the new code is only in one place. Once I have a chance to review how Xamarin does Behaviors I'll probably write a Behavior that does what I need it to do. I prefer that route to putting on extensions or inheriting from the OEM control. Its just less 'dirty' to me. I'm not against extension methods for pure data types, but I don't like them for UI elements. I'm happy to write an extension method of .ToMilSpec() for DateTime. But I wouldn't add a MilSpec extension method to a DateTimePickerControl. Just a personal preference.

    We all have our own preferred styles. That's what makes coding as much an art as a science. It can be nice that there are many right ways to do something.

  • FrancoisMFrancoisM FRUniversity ✭✭

    When a ListView is already at bottom and you add an item to the ObservableCollection, ItemAppearing is not fired...

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    ItemAppearing is not ItemAddedToCollection - It means item that was not in view is now coming in to view... it is appearing in view.

    So if the ListView already has several items that run off screen, and you add an item that would not and should not raise an ItemAppearing event. - You already get an event from CollectionChanged for changes to the collection.

    If the ListView is "at bottom" as you describe, and you add an item the new item is off screen. So again, it would not raise "ItemAppearing" because it has not yet appeared.

    That's the point I'm making... When a new item is added I would like to be able to scroll to it, causing it to appear.

    As the documentation says:

    Happens when the element is added to the visual layout.

  • RaymondKellyRaymondKelly USMember ✭✭✭

    @ClintStLaurent I have been struggling with this for over month. I finally got Android and UWP worked out but it appears it is impossible to scroll to the bottom on iOS. There are several threads on this issue, here are two:

    http://forums.xamarin.com/discussion/comment/211759/#Comment_211759
    http://forums.xamarin.com/discussion/comment/211763/#Comment_211763

    I believe the root issue is that scrollTo gets called while the ListView is updating and "ContentSize.Height" is just wrong.

    private void ScrollToRow(int itemIndex, int sectionIndex, bool animated) { if(itemIndex == -1) { return; } if (pinToBottom && Control!=null) { Control.LayoutIfNeeded(); Debug.WriteLine(Control.ContentSize.Height.ToString() + ":" + Control.Frame.Size.Height.ToString()); CGPoint offset = new CGPoint(0, Control.ContentSize.Height - Control.Frame.Size.Height); if (offset.Y < 0.0f) { return; } Debug.WriteLine("Scroll: " + offset.ToString()); Control.SetContentOffset(offset, false); } }

    This works about 70% of the time. When I hit this code Control.ContentSize.Height sometimes returns a smaller value than the previous, and that should never happen since I am only adding rows. This same code works perfectly on Xamarin for iOS, I have had that code out for 3 years, but it just does not work in XF. I spent about 12 hours yesterday fooling with it. I was able to turn off ListView animations by coding around my elbow so it would paint fast, but had the same result.

    I am out of ideas and dead in the water. I really wish we could get some heavy weights like @AdamP. @rmarinho and @BryanHunterXam to get all these ListView items pushed to the top of the bug queue. Its been too broken for too long.

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    Agreed @RaymondKelly - As much as Xamarin pushes ListViews, their importance, the number of Xamarin University course dedicated to them, their prominence in certification, etc. etc. - to the point where it begins to feel like ListViews are the end-all-be-all of mobile coding in THEIR eyes - they sure don't seem to show the same commitment to supporting them.

  • Since i cant post link i just copy the right answer from stackoverflow

    Viewmodel.cs on collection change add :
    MessagingCenter.Send<object, object> (this, "MessageReceived", lastReceivedMessage);

    • where lastReceivedMessage is last item in ur collection

    At
    PageX.xaml.cs add :
    MessagingCenter.Subscribe<object, object> (this, "MessageReceived", (sender, arg) => {
    MainScreenMessagesListView.ScrollTo(arg, ScrollToPosition.End, true);
    });

    --Done :)

  • CaptainXamtasticCaptainXamtastic GBUniversity ✭✭✭
    edited July 13

    @sergiitokarchuk said:
    Since i cant post link i just copy the right answer from stackoverflow

    Viewmodel.cs on collection change add :
    MessagingCenter.Send<object, object> (this, "MessageReceived", lastReceivedMessage);

    • where lastReceivedMessage is last item in ur collection

    At
    PageX.xaml.cs add :
    MessagingCenter.Subscribe<object, object> (this, "MessageReceived", (sender, arg) => {
    MainScreenMessagesListView.ScrollTo(arg, ScrollToPosition.End, true);
    });

    --Done :)

    @sergiitokarchuk Splendid idea, I used your base pattern to create a custom control using this pattern, but so that I could use it using Mvvm, I added a bindable property to the control ('AddedItemMessagingCenterMessageKey') and also bound this to a property in the ViewModel, the idea being that the key was declared in the ViewModel, the item was sent in the ViewModel by the MessagingCenter busing that declared key, but the MessagingCenter in the control had first subscribed to the message using the property AddedItemMessagingCenterMessageKey in the control that was bound to the ViewModel.

    Quite a nice pattern as it gets it all out of the way of the code behind in the page, as well as being reusable in the rest of the app.

    At which point I added a few other bindable properties, here they are in case someone wants a leg-up, they are self explanatory:

        public class ScrollableListView : ListView
        {
            #region AutoScrollDirection AutoScrollDirection [BindableProperty]
            public static readonly BindableProperty AutoScrollDirectionProperty =
                 BindableProperty.CreateAttached(
                      "AutoScrollDirection",
                      typeof(AutoScrollDirection),
                      typeof(AutoScrollDirection),
                      AutoScrollDirection.Bottom,
                      BindingMode.OneWay);
    
            public AutoScrollDirection AutoScrollDirection
            {
                get { return (AutoScrollDirection)GetValue(AutoScrollDirectionProperty); }
                set { SetValue(AutoScrollDirectionProperty, value); }
            }
            #endregion
    
            #region string AddedItemMessagingCenterMessageKey [BindableProperty]
            public static readonly BindableProperty AddedItemMessagingCenterMessageKeyProperty =
                 BindableProperty.CreateAttached(
                      "AddedItemMessagingCenterMessageKey",
                      typeof(string),
                      typeof(string),
                    string.Empty,
                    BindingMode.OneWay);
    
            public string AddedItemMessagingCenterMessageKey
            {
                get { return (string)GetValue(AddedItemMessagingCenterMessageKeyProperty); }
                set { SetValue(AddedItemMessagingCenterMessageKeyProperty, value); }
            }
            #endregion
    
            #region bool AutoDeselectItem [BindableProperty]
            public static readonly BindableProperty AutoDeselectItemProperty =
                 BindableProperty.CreateAttached(
                      "AutoDeselectItem",
                      typeof(bool),
                      typeof(bool),
                      true,
                      BindingMode.OneWay);
    
            public bool AutoDeselectItem
            {
                get { return (bool)GetValue(AutoDeselectItemProperty); }
                set { SetValue(AutoDeselectItemProperty, value); }
            }
            #endregion
    
            private bool _isSubscribedAddedItemMessagingCenterMessageKey;
            private string _lastSubscribedAddedItemMessagingCenterMessageKeyName;
    
            public ScrollableListView()
            {
    
                this.ItemSelected += (object sender, SelectedItemChangedEventArgs e) =>
                {
                    if (AutoDeselectItem)
                    {
                        this.SelectedItem = null; // this deselects the colour of the previously selected item.
                    }
                };
    
                this.PropertyChanged += (object sender, System.ComponentModel.PropertyChangedEventArgs e) => 
                {
                    if(e.PropertyName == ScrollableListView.AddedItemMessagingCenterMessageKeyProperty.PropertyName)
                    {
                        // as this property is bindable (hence may change), and as assignment of this property 
                        // causes a MessagingCenter subscription, we must always unassign it if assigned previously.
    
                        if (_isSubscribedAddedItemMessagingCenterMessageKey)
                        {
                            UnSubscribeLastAddedItemMessagingCenterMessageKey();
                        }
    
                        if (!string.IsNullOrEmpty(AddedItemMessagingCenterMessageKey)) // It is left unsubscribed if value is string.Empty.
                        {
                            SubscribeAddedItemMessagingCenterMessageKey();
                        }
                    }
                };
            }
    
            private void UnSubscribeLastAddedItemMessagingCenterMessageKey()
            {
                MessagingCenter.Unsubscribe<object, object>(this, _lastSubscribedAddedItemMessagingCenterMessageKeyName);
    
                _isSubscribedAddedItemMessagingCenterMessageKey = false;
                _lastSubscribedAddedItemMessagingCenterMessageKeyName = string.Empty;
            }
    
            private void SubscribeAddedItemMessagingCenterMessageKey()
            {
                MessagingCenter.Subscribe<object, object>(this, AddedItemMessagingCenterMessageKey, (sender, arg) =>
                {
                    switch (AutoScrollDirection)
                    {
                        case AutoScrollDirection.Top:
                            this.ScrollTo(arg, ScrollToPosition.Start, true); // makes sure, when displaying that item, it shows the top of it
                            break;
    
                        case AutoScrollDirection.Bottom:
                            this.ScrollTo(arg, ScrollToPosition.End, true); // makes sure, when displaying that item, it shows the bottom of it
                            break;
                    }
    
                });
    
                _isSubscribedAddedItemMessagingCenterMessageKey = true;
                _lastSubscribedAddedItemMessagingCenterMessageKeyName = AddedItemMessagingCenterMessageKey;
            }
        }
    

    which binds to the VM as follows:

                                    <controls:ScrollableListView 
                                        x:Name="[Redacted]ListView"
                                        AutoScrollDirection="{Binding AutoScrollDirection}"
                                        AddedItemMessagingCenterMessageKey="{Binding AddedItemMessagingCenterMessageKey}"
                                        AutoDeselectItem="{Binding AutoDeselectItem}"
                                        ItemsSource="{Binding MyDataList}"
                                        ItemTemplate="{StaticResource [Redacted]DataTemplateSelector}"
                                        SelectedItem="{Binding SelectedItem}"
                                        HasUnevenRows="true"
                                        SeparatorVisibility="None"
                                        IsPullToRefreshEnabled="true"
                                        IsRefreshing="{Binding IsRefreshing}"
                                        RefreshCommand="{Binding RefreshDataCommand}"
                                        RelativeLayout.HeightConstraint="{ConstraintExpression Type=RelativeToParent,Property=Height,Factor=1,Constant==0}"
                                        />
    

    And the following in the VM sends the message:

     private void RefreshData()
     {
          Device.BeginInvokeOnMainThread(() =>
          {
               IsRefreshing = true;
               MyDataList = [Redacted];
               IsRefreshing = false;
               if(_isDataAdded)
               {
                   MessagingCenter.Send<object, object>(this, AddedItemMessagingCenterMessageKey, MyDataList[MyDataList.Count-1]);
                    _isDataAdded = false;
               }
                    } );
    }
    

    Naturally, the RefreshData() method is called after the item has been added to the collection. I call this method after adding data as well as refreshing, so the _isDataAdded makes sure I don't send a message if the user refreshes.

    Thanks for the base pattern!

  • CaptainXamtasticCaptainXamtastic GBUniversity ✭✭✭

    I upgraded the above to do the ScrollTo when the ItemSource property changed (in the this.PropertyChanged += section):

                    if (e.PropertyName == ScrollableListView.ItemsSourceProperty.PropertyName)
                    {
                        if(_lastItemAdded != null)
                        {
                            switch (AutoScrollDirection)
                            {
                                case AutoScrollDirection.Top:
                                    this.ScrollTo(_lastItemAdded, ScrollToPosition.Start, true); // makes sure, when displaying that item, it shows the top of it
                                    break;
    
                                case AutoScrollDirection.Bottom:
                                    this.ScrollTo(_lastItemAdded, ScrollToPosition.End, true); // makes sure, when displaying that item, it shows the bottom of it
                                    break;
                            }
                            _lastItemAdded = null;
                        }
                    }
    

    and this meant updating the following:

            private void SubscribeAddedItemMessagingCenterMessageKey()
            {
                MessagingCenter.Subscribe<object, object>(this, AddedItemMessagingCenterMessageKey, (sender, arg) =>
                {
                    _lastItemAdded = arg;
                });
    
                _isSubscribedAddedItemMessagingCenterMessageKey = true;
                _lastSubscribedAddedItemMessagingCenterMessageKeyName = AddedItemMessagingCenterMessageKey;
            }
    

    in which private object _lastItemAdded; was added to support the above change.

    This means that if the ItemSource update is slower than the messaging assignment, the ScrollTo is still guaranteed; without this change, if the messaging occurs before the ItemsSource update, the new item to ScrollTo wont be in the list.

Sign In or Register to comment.