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

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