Skip to content

Instantly share code, notes, and snippets.

@kriscoleman
Last active September 6, 2016 20:17
Show Gist options
  • Save kriscoleman/3f37bf11bd9bb12e4c0e32f8628553c6 to your computer and use it in GitHub Desktop.
Save kriscoleman/3f37bf11bd9bb12e4c0e32f8628553c6 to your computer and use it in GitHub Desktop.
A simpler way of declaring UITableView content in Xamarin.iOS
//Task: Show Customer Cells with the following behaviors:
// - Customer Name as Cell Title
// - Customer Address as Cell Subtitle
// - Customer Logo as Cell Image
// - Implement Swipe action to delete customers
public class MyEZTableViewSource : EZTableViewSource<EZRow>
{
List<Customer> _customers;
public MyEzTableViewCource(IEnumerable<Customers> customers, UITableViewController parent) : base (parent)
{
_customers = customers.ToList();
}
protected override IEnumerable<EZSection<EZRow>> ConstructSections()
{
yield return new EZSection("My Customers") // string literal here will create Section Header
{
_customers.Select(c=> new EZRow(c, c.Name, c.Address.ToDisplayFormat()).WithImage(c.Logo).WithEditActions(new []
{
UITableViewRowAction.Create(UITableViewRowActionStyle.Descrutive, "Delete", (action, path) =>
{
_customers.Remove(c);
Parent.TableView.DeleteRows(new []{path}, UITableViewRowAnimation.Fade);
}
}))
};
}
}
//Task: Show Customer Cells with the following behaviors:
// - Customer Name as Cell Title
// - Customer Address as Cell Subtitle
// - Customer Logo as Cell Image
// - Implement Swipe action to delete customers
public class MyLegacyTableViewSource : UITableViewSource
{
public MyLegacyTableViewSource(IEnumerable<Customers> customers)
{
_customers = customers.ToList();
}
public override nint NumberOfSections(UITableView tableView) => 1;
public override nint NumberOfRowsInSection(UITableView tableView, nint section) => _customers.Count;
public override string TitleForHeader(UITableView tableview, nint section) => "My Customers"; // ignore params because we know we only have 1 section
public override UITableViewCell GetCell(UiTableView tableView, NSIndexPath indexPath)
{
var customer = _customers.ElementAt(indexPath.Row);
var cell = TableView.DequeueReusableCell("cell");
cell.TextLabel.Text = customer.Name;
cell.DetailTextLabel.Text = customer.Address.ToDisplayFormat();
cell.ImageView.Image = customer.Logo;
}
public override bool CanEditRow(UITableView tableView, NSIndexPath indexPath) => true;
public override UITableViewRowAction[] EditActionsForRow(UITableView tableView, NSIndexPath indexPath)
{
return new [] {UITableViewRowAction.Create(UITableViewRowActionStyle.Descrutive, "Delete", null)};
};
public override void CommitEditingStyle(UITableView tableView, UITableViewCellEditingStyle editingStyle, NSIndexPath indexPath)
{
if (editingStyle != UITableViewCellEditingStyle.Delete)
return;
_customers.Remove(_customer.ElementAt(indexPath.Row;)
tableView.DeleteRows(new []{path}, UITableViewRowAnimation.Fade);
}
}
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Foundation;
using UIKit;
using Xamarin.EZiOS.Interfaces;
namespace Xamarin.EZiOS
{
/// <summary>
/// Simplifies the job of a UITableViewSource.
/// </summary>
/// <typeparam name="T">The type this collection contains</typeparam>
/// <seealso cref="UIKit.UITableViewSource" />
public abstract class EZTableViewSource<T> : UITableViewSource where T : class, IEZRow<T>
{
protected readonly UITableViewController ParentViewController;
protected EZTableViewSource(UITableViewController parentViewController)
{
ParentViewController = parentViewController;
}
/// <summary>
/// The list of EZSections, which each contain Rows.
/// </summary>
public List<EZSection<T>> EZSections { get; protected set; }
/// <summary>
/// Gets or sets the can edit row function for the entire TableView
/// </summary>
public Func<UITableView, NSIndexPath, bool> CanEditRowFunc { get; set; }
/// <summary>
/// Refreshes the sections.
/// Will Clear the sections first, cleanup any resources from the old sections, and reset them.
/// </summary>
protected internal virtual void RefreshSections()
{
ClearSectionsAndUnhookSubscriptions();
EZSections.AddRange(ConstructSections());
ParentViewController.TableView.ReloadData();
}
/// <summary>
/// Clears the sections and unhook subscriptions.
/// </summary>
protected internal void ClearSectionsAndUnhookSubscriptions()
{
EZSections.Clear();
//todo: unhook cell events when after we implement them
}
/// <summary>
/// Constructs the sections.
/// </summary>
protected abstract IEnumerable<EZSection<T>> ConstructSections();
/// <summary>
/// Gets the row or default (null) if the IndexPath is out of Range.
///
/// </summary>
/// <param name="indexPath">The index path.</param>
public IEZRow<T> GetRowOrDefault(NSIndexPath indexPath)
=> indexPath.IsOutOfRange(EZSections) ? null : EZSections[indexPath.Section][indexPath.Row];
/// <summary>
/// Deques the reusable cell.
/// </summary>
/// <param name="tableView">The table view.</param>
/// <param name="row">The row.</param>
/// <returns></returns>
protected virtual UITableViewCell DequeReusableCell(UITableView tableView, IEZRow<T> row)
=> tableView.DequeueReusableCell(string.IsNullOrWhiteSpace(row.ReuseIdentifier)
? "cell"
: row.ReuseIdentifier);
/// <summary>
/// Applies the default styles to cell.
/// </summary>
/// <param name="cell">The cell.</param>
/// <param name="row">The row.</param>
/// <returns></returns>
protected virtual UITableViewCell ApplyDefaultStyleToCell(UITableViewCell cell, IEZRow<T> row)
{
cell.TextLabel.Text = row.Title;
if (row.CellStyle == UITableViewCellStyle.Subtitle)
cell.DetailTextLabel.Text = row.SubTitle;
cell.Accessory = row.CellAccessory;
var ezRow = row as EZRow<T>;
return ezRow == null ? cell : ApplyDefaultStyleToEZRowConcrete(ezRow, cell);
}
/// <summary>
/// Applies the default styles to EZRow concrete.
/// </summary>
/// <param name="ezRow">The EZRow.</param>
/// <param name="cell">The cell.</param>
/// <returns></returns>
protected virtual UITableViewCell ApplyDefaultStyleToEZRowConcrete(EZRow<T> ezRow, UITableViewCell cell)
{
if (ezRow.Image != null)
cell.ImageView.Image = ezRow.Image;
return cell;
}
#region Overrides of UITableViewSource
/// <summary>
/// Returns the number of sections that are required to display the data.
/// </summary>
/// <param name="tableView">Table view displaying the sections.</param>
/// <returns>
/// Number of sections required to display the data. The default is 1 (a table must have at least one section).
/// </returns>
/// <remarks>
/// Declared in [UITableViewDataSource]
/// </remarks>
public override nint NumberOfSections(UITableView tableView) => EZSections.Count;
/// <summary>
/// Called by the table view to find out how many rows are to be rendered in the section specified by
/// <paramref name="section" />.
/// </summary>
/// <param name="tableview">Table view displaying the rows.</param>
/// <param name="section">Index of the section containing the rows.</param>
/// <returns>
/// Number of rows in the section at index <paramref name="section" />.
/// </returns>
/// <remarks>
/// Declared in [UITableViewDataSource]
/// </remarks>
public override nint RowsInSection(UITableView tableview, nint section) => EZSections[(int)section].Count;
#region Overrides of UITableViewSource
/// <summary>
/// Called to populate the header for the specified section.
/// </summary>
/// <returns>
/// Text to display in the section header, or <see langword="null"/> if no title is required.
/// </returns>
public override string TitleForHeader(UITableView tableView, nint section) => EZSections[(int) section].HeaderTitle;
#region Overrides of UITableViewSource
/// <summary>
/// Called to populate the footer for the specified section.
/// </summary>
/// <returns>
/// Text to display in the section footer, or <see langword="null"/> if no title is required.
/// </returns>
public override string TitleForFooter(UITableView tableView, nint section) => EZSections[(int) section].FooterTitle;
#endregion
#endregion
/// <summary>
/// Called by the table view to populate the row at <paramref name="indexPath" /> with a cell view.
/// Will attempt to dequeue a reusable cell from tableView. If a reuseIdentifier is found, it will use it, else it will
/// use the default string "cell".
/// The Reuse Identifier is set in the storyboard, or manually in code-behind, and is a unique identifier for the table
/// view to cache reusable cells.
/// Will also attempt to apply EZ default styles to the cell, or any cell styles you configure.
/// </summary>
/// <param name="tableView">Table view requesting the cell.</param>
/// <param name="indexPath">Location of the row where the cell will be displayed.</param>
/// <returns>
/// An object that inherits from <see cref="T:UIKit.UITableViewCell" /> that the table can use for the specified row.
/// Do not return <see langword="null" /> or an assertion will be raised.
/// </returns>
/// <remarks>
/// <para>
/// This method is called once for each row that is visible on screen. During scrolling, it is called additional
/// times as new rows come into view. Cells that disappear from view are cached by the table view. The
/// implementation of this method should call the table view's
/// <see cref="M:UIKit.UITableView.DequeueReusableCell(Foundation.NSString)" /> method to obtain a cached cell
/// object for reuse (if <see langword="null" /> is returned, create a new cell instance). Be sure to reset all
/// properties of a reused cell.
/// </para>
/// <para>Declared in [UITableViewDataSource]</para>
/// </remarks>
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath)
{
var row = GetRowOrDefault(indexPath);
Debug.Assert(row != null, IndexPathOutOfRangeAssertionMessage(indexPath));
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
if (row == null)
{
WriteIndexPathOutOfRangeMessageToConsole(indexPath);
return new UITableViewCell(); //bail in case of race condition
}
var cell = DequeReusableCell(tableView, row);
return ApplyDefaultStyleToCell(cell, row);
}
/// <summary>
/// Whether the row located at <paramref name="indexPath" /> should be editable.
/// Row behavior overrides Table-Wide behavior.
/// If this method is not implemented, all rows are assumed to be non-editable. (This differs from the default iOS
/// behavior, where the default if not implemented is editable)
/// </summary>
/// <param name="tableView">Table view containing the row.</param>
/// <param name="indexPath">Location of the row.</param>
/// <returns>
/// <see langword="true" /> if the row is editable, otherwise <see langword="false" />.
/// </returns>
/// <remarks>
/// <para>
/// This method allows specific rows to be editable or not. Editable rows display the insertion or deletion
/// control in their cell when the table view is in editing mode, or allow for swipte actions.
/// </para>
/// <para>
/// Rows that are not editable will ignore the <see cref="P:UIKit.UITableViewCell.EditingStyle" /> property and
/// will not be indented.
/// </para>
/// <para>
/// Rows that are editable, but should not display the insertion or deletion control, can return
/// <see cref="F:UIKit.UITableViewCellEditingStyle.None" /> from the
/// <see cref="M:UIKit.UITableViewSource.EditingStyleForRow(UIKit.UITableView,Foundation.NSIndexPath)" /> method on
/// the table view's <see cref="T:UIKit.UITableViewSource" />.
/// </para>
/// <para>Declared in [UITableViewDataSource]</para>
/// </remarks>
public override bool CanEditRow(UITableView tableView, NSIndexPath indexPath)
{
var row = GetRowOrDefault(indexPath);
Debug.Assert(row != null, IndexPathOutOfRangeAssertionMessage(indexPath));
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
if (row == null)
{
WriteIndexPathOutOfRangeMessageToConsole(indexPath);
return false; //bail in case of race condition
}
var canEditRowOverride = row.EditRowActions?.Any() ?? false;
if (canEditRowOverride)
return true; //row behavior overrides tableview behavior
return CanEditRowFunc?.Invoke(tableView, indexPath) ?? false; //check tableview behavior last
}
#endregion
void WriteIndexPathOutOfRangeMessageToConsole(NSIndexPath indexPath) => Console.WriteLine(IndexPathOutOfRangeAssertionMessage(indexPath));
string IndexPathOutOfRangeAssertionMessage(NSIndexPath indexPath)
=>
"Race condition with Cocoa animation thread likely: indexPath was out of range. " +
$"Current length of EZSections: {EZSections.Count} - " +
$"IndexPath values: {indexPath.Section}(section), {indexPath.Row}(row)";
}
}
using System;
using System.Collections.Generic;
using Foundation;
using UIKit;
using Xamarin.EZiOS.Interfaces;
namespace Xamarin.EZiOS
{
public static class EZTableViewSourceHelper
{
/// <summary>
/// Will add an image to the row.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="row">The row.</param>
/// <param name="image">The image.</param>
/// <returns></returns>
public static EZRow<T> WithImage<T>(this EZRow<T> row, UIImage image)
{
row.Image = image;
return row;
}
/// <summary>
/// Will apply the cell accessory to the row.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="row">The row.</param>
/// <param name="cellAccessory">The cell accessory.</param>
/// <returns></returns>
public static IEZRow<T> WithCellAccessory<T>(this IEZRow<T> row, UITableViewCellAccessory cellAccessory)
{
row.CellAccessory = cellAccessory;
return row;
}
/// <summary>
/// Will apply the cell reuse identifier to the row.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="row">The row.</param>
/// <param name="cellReuseIdentifier">The cell reuse identifier.</param>
/// <returns></returns>
public static IEZRow<T> WithCellReuseIdentifier<T>(this IEZRow<T> row, string cellReuseIdentifier)
{
row.ReuseIdentifier = cellReuseIdentifier;
return row;
}
/// <summary>
/// Will apply the cell reuse identifier to the row.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="row">The row.</param>
/// <param name="getCellReuseIdentifierFunc">The get cell reuse identifier function.</param>
/// <returns></returns>
public static EZRow<T> WithCellReuseIdentifier<T>(this EZRow<T> row, Func<T, string> getCellReuseIdentifierFunc)
{
row.GetCellReuseIdentifierFunc = getCellReuseIdentifierFunc;
return row;
}
/// <summary>
/// Will apply the EditActiosn to the row.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="row">The row.</param>
/// <param name="editRowActions">The edit row actions.</param>
/// <returns></returns>
public static IEZRow<T> WithEditActions<T>(this IEZRow<T> row, UITableViewRowAction[] editRowActions)
{
row.EditRowActions = editRowActions;
return row;
}
/// <summary>
/// Determines whether the indexPath is out of range of the specified sections.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="indexPath">The index path.</param>
/// <param name="sections">The sections.</param>
/// <returns>
/// <c>true</c> if [is out of range] [the specified sections]; otherwise, <c>false</c>.
/// </returns>
public static bool IsOutOfRange<T>(this NSIndexPath indexPath, List<EZSection<T>> sections) where T : class, IEZRow<T>
{
if (indexPath.Section < 0 || indexPath.Section + 1 > sections.Count)
return true;
return indexPath.Row < 0 || indexPath.Row + 1 > sections[indexPath.Section].Count;
}
}
}
@kriscoleman
Copy link
Author

As you can see, the EZ concrete of TableViewSource significantly reduces the amount of boiler plate needed to display our UI. It also brings all of the view logic for displaying our sections/rows into a single method, so we don't have to bounce around a file to change behaviors.

It also simplifies the view logic by allowing us to declare the Sections/Rows, in a very declarative nature. In the legacy version, we can style the cell in GetCell but have to implement other things like EditRowActions in separate methods. The EZ version brings this all to the forefront, making all cell style and behaviors declarative from our cell provider (EZRow).

@kriscoleman
Copy link
Author

kriscoleman commented Sep 6, 2016

Note on IndexPath Exceeding Range:
In my experience with iOS, sometimes the UI can encounter a race condition with the animation thread. I only see this in rare circumstances. Namely A) when a lot of data is being parsed (thousands upon thousands of records), or B) when a lot of TableView.ReloadData calls are made in a tight loop (this typically isn't on purpose, but can happen on accident if a developer isn't frugal with these calls).

When this happens, the indexPath returned will be negative, or sometimes I've observed it being astronomical (really high).
Unhandled, this will cause a crash in the wild. I found that if I bail, the app usually will recover on it's own when the animation thread catches up. So I simply log the occurrence in release. In debug, I assert that this doesn't happen, so while developing I can get a popup and breakpoint when it occurs and investigate it. I found this was a better way to handle these rare occurrences than having users deal with a possibly crashing app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment