0

I am wanting to create a page layout where I display multiple TableViews along with other views such as a Label in between each.

In doing this I need the ability to:

  1. Make the TableView not default to a vertically expanded layout option (the default which does not seem changeable) so that they can be stacked in a StackLayout vertically.
  2. Hide the corresponding UITableView section header/footers so that there isn't excess vertical spacing between.

I have achieved this with the following CustomTableView control:

public class CustomTableView : TableView
{
    public bool NoHeader { get; set; }
    public bool NoFooter { get; set; }

    protected override SizeRequest OnSizeRequest(double widthConstraint, double heightConstraint)
    {
        if(!VerticalOptions.Expands)
        {
            // Call OnSizeRequest that is overwritten in custom renderer
            var baseOnSizeRequest = GetVisualElementOnSizeRequest();
            return baseOnSizeRequest(widthConstraint, heightConstraint);
        }

        return base.OnSizeRequest(widthConstraint, heightConstraint);
    }

    public Func<double, double, SizeRequest> GetVisualElementOnSizeRequest()
    {
        var handle = typeof(VisualElement).GetMethod(
            "OnSizeRequest",
            BindingFlags.Instance | BindingFlags.NonPublic,
            null,
            new Type[] { typeof(double), typeof(double) },
            null)?.MethodHandle;

        var pointer = handle.Value.GetFunctionPointer();
        return (Func<double, double, SizeRequest>)Activator.CreateInstance(
            typeof(Func<double, double, SizeRequest>), this, pointer);
    }
}

and the custom iOS renderer for this which follows the advise from this SO answer for hiding UITableView headers/footers:

public class CustomTableViewRenderer : TableViewRenderer
{
    public override SizeRequest GetDesiredSize(double widthConstraint, double heightConstraint)
    {
        Control.LayoutIfNeeded();
        var size = new Size(Control.ContentSize.Width, Control.ContentSize.Height);
        return new SizeRequest(size);
    }

    protected override void OnElementChanged(ElementChangedEventArgs<TableView> e)
    {
        base.OnElementChanged(e);

        if(e.NewElement is CustomTableView view)
        {
            Control.ScrollEnabled = false;
            SetSource();
        }
    }

    protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        base.OnElementPropertyChanged(sender, e);
        var view = (CustomTableView)Element;
        if(e.PropertyName == TableView.HasUnevenRowsProperty.PropertyName)
        {
            SetSource();
        }
    }

    private void SetSource()
    {
        var view = (CustomTableView)Element;
        if(view.NoFooter || view.NoHeader)
        {
            Control.Source = new CustomTableViewModelRenderer(view);
        }
        else
        {
            Control.Source = Element.HasUnevenRows ? new UnEvenTableViewModelRenderer(Element) :
                new TableViewModelRenderer(Element);
        }
    }

    public class CustomTableViewModelRenderer : UnEvenTableViewModelRenderer
    {
        private readonly CustomTableView _view;

        public CustomTableViewModelRenderer(CustomTableView model)
            : base(model)
        {
            _view = model;
        }

        public override nfloat GetHeightForRow(UITableView tableView, NSIndexPath indexPath)
        {
            if(_view.HasUnevenRows)
            {
                return UITableView.AutomaticDimension;
            }

            return base.GetHeightForRow(tableView, indexPath);
        }

        public override nfloat GetHeightForHeader(UITableView tableView, nint section)
        {
            if(_view.NoHeader)
            {
                return 0.00001f;
            }

            return base.GetHeightForHeader(tableView, section);
        }

        public override UIView GetViewForHeader(UITableView tableView, nint section)
        {
            if(_view.NoHeader)
            {
                return new UIView(new CGRect(0, 0, 0, 0));
            }

            return base.GetViewForHeader(tableView, section);
        }

        public override nfloat GetHeightForFooter(UITableView tableView, nint section)
        {
            if(_view.NoFooter)
            {
                return 0.00001f;
            }

            return 10f;
        }

        public override UIView GetViewForFooter(UITableView tableView, nint section)
        {
            if(_view.NoFooter)
            {
                return new UIView(new CGRect(0, 0, 0, 0));
            }

            return base.GetViewForFooter(tableView, section);
        }
    }
}

I then create a simple Xamarin.Forms ContentPage to implement this as such:

public class TablePage : ContentPage
{
    public TablePage()
    {
        Table1 = new PageTableView
        {
            BackgroundColor = Color.Cyan,
            Root = new TableRoot
            {
                new TableSection("Table 1")
                {
                    new LabelEntryTableCell("Label A"),
                    new LabelEntryTableCell("Label B")
                }
            }
        };

        var label1 = new Label
        {
            Text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
                "Vivamus accumsan lacus orci. Nulla in enim erat.",
            Margin = new Thickness(15, 5, 15, 0),
            BackgroundColor = Color.Yellow
        };

        Table2 = new PageTableView
        {
            BackgroundColor = Color.Red,
            Root = new TableRoot
            {
                new TableSection("Table 2")
                {
                    new LabelEntryTableCell("Label C"),
                    new LabelEntryTableCell("Label D")
                }
            }
        };

        var label2 = new Label
        {
            Text = "Duis vulputate mattis elit. " +
                "Donec vulputate lorem vitae elit posuere, quis consequat ligula imperdiet.",
            Margin = new Thickness(15, 5, 15, 0),
            BackgroundColor = Color.Green
        };

        StackLayout = new StackLayout
        {
            Children = { Table1, label1, Table2, label2 },
            Spacing = 0
        };

        BackgroundColor = Color.Gray;
        Title = "Custom Table View";
        Content = new ScrollView
        {
            Content = StackLayout
        };
    }

    public PageTableView Table1 { get; set; }
    public PageTableView Table2 { get; set; }
    public StackLayout StackLayout { get; set; }
}

public class PageTableView : CustomTableView
{
    public PageTableView()
    {
        Intent = TableIntent.Settings;
        HasUnevenRows = true;
        RowHeight = -1;
        VerticalOptions = LayoutOptions.Start;
        NoFooter = true;
    }
}

public class LabelEntryTableCell : ViewCell
{
    public LabelEntryTableCell(string labelText)
    {
        var label = new Label { Text = labelText };
        var entry = new Entry();

        View = new StackLayout
        {
            Children = { label, entry },
            BackgroundColor = Color.White,
            Padding = 10
        };
    }
}

This all seems to work as expected, however, when the TablePage first displays much of the CustomTableView content is chopped off. I can then rotate the device to landscape and the problem corrects itself, showing my layout as I intend. Then rotating then device back to portrait still shows the correct layout with no more information from the CustomTableView being chopped off anymore in that orientation either. This happens consistently every time. See a demo of this here:

Rotating fixes the problem

I can also fix the problem by adding this to the TablePage:

protected override void OnAppearing()
{
    base.OnAppearing();

    // This will also fix the problem, but you see a flash of the old layout
    // when the page first displays.

    StackLayout.RedrawLayout();
}

...

public class CustomStackLayout : StackLayout
{
    public void RedrawLayout()
    {
        InvalidateLayout();
    }
}

How do I correct this problem? My custom control/renderer/page works as intended whenever I do the rotation/OnAppearing hack to correct it and I am not sure why. It seems like I need to call some re-draw method on the UITableView.

Sample Project

If you want to see the full source/project for this isolated test scenario you can find it on GitHub here.

kspearrin
  • 10,238
  • 9
  • 53
  • 82

1 Answers1

1

I knew this issue in iOS. When the tableView first loaded, we can't get the correct contentSize even if we have written LayoutIfNeeded(). But after the page is appearing then we refresh it, we can get the correct contentSize. This is why

This will also fix the problem, but you see a flash of the old layout

Solution1: My suggestion is we can try to refresh the tableView in a bit milliseconds delay when the tableView will dispaly:

In the TablePage, I subscribe a MessagingCenter to refresh the layout

MessagingCenter.Subscribe<object>(this, "Refresh", (sender) =>
{
    StackLayout.RedrawLayout();
});

Then I send the message in the renderer:

//I define this bool field to make sure this message only be sent at the first time after tableView loaded
private bool shouldRefresh = true;

public override async void WillDisplay(UITableView tableView, UITableViewCell cell, NSIndexPath indexPath)
{

    if (tableView.NumberOfRowsInSection(0) - 1 == indexPath.Row)
    {
        if (shouldRefresh)
        {
            await Task.Delay(10);
            MessagingCenter.Send<object>(this, "Refresh");
            shouldRefresh = false;
        }  
    }
}

Solutin2: We can also try to define an estimated rowHeight to give a contentSize when the tableView first loaded:

public override nfloat EstimatedHeight(UITableView tableView, NSIndexPath indexPath)
{
    return 100;
}

But in this solution the height must be predefined and calculated by ourselves.

Ax1le
  • 6,563
  • 2
  • 14
  • 61
  • Thanks for the tips on overriding `WillDisplay`. I was able to create a solution based off of this idea, though without using `MessagingCenter`. I just passed a reference of the wrapping `CustomStackLayout` to the `CustomTableView` and call `RedrawLayout` from `WillDisplay` in the renderer. Works, but seems kind of a hacky still. I would prefer to be able to calculate the proper height somehow initially if possible. I will accept your answer if no other good solutions come in. – kspearrin Jan 03 '18 at 15:30
  • @kspearrin yeap, I know it’s the best if we can find a way to calculate the height at initial time. – Ax1le Jan 03 '18 at 15:56
  • @kspearrin Excuse me, have you found a better way to solve this problem? – Ax1le Jan 16 '18 at 05:38