using System; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using Foundation; using UIKit; using Xamarin.Forms.Platform.iOS.Resources; using PointF = CoreGraphics.CGPoint; using RectangleF = CoreGraphics.CGRect; using SizeF = CoreGraphics.CGSize; namespace Xamarin.Forms.Platform.iOS { internal class ContextActionsCell : UITableViewCell, INativeElementView { public const string Key = "ContextActionsCell"; static readonly UIImage DestructiveBackground; static readonly UIImage NormalBackground; readonly List _buttons = new List(); readonly List _menuItems = new List(); Cell _cell; UIButton _moreButton; UIScrollView _scroller; UITableView _tableView; static ContextActionsCell() { var rect = new RectangleF(0, 0, 1, 1); var size = rect.Size; UIGraphics.BeginImageContext(size); var context = UIGraphics.GetCurrentContext(); context.SetFillColor(1, 0, 0, 1); context.FillRect(rect); DestructiveBackground = UIGraphics.GetImageFromCurrentImageContext(); context.SetFillColor(UIColor.LightGray.ToColor().ToCGColor()); context.FillRect(rect); NormalBackground = UIGraphics.GetImageFromCurrentImageContext(); context.Dispose(); } public ContextActionsCell() : base(UITableViewCellStyle.Default, Key) { } public ContextActionsCell(string templateId) : base(UITableViewCellStyle.Default, Key + templateId) { } public UITableViewCell ContentCell { get; private set; } public bool IsOpen { get { return ScrollDelegate.IsOpen; } } ContextScrollViewDelegate ScrollDelegate { get { return (ContextScrollViewDelegate)_scroller.Delegate; } } Element INativeElementView.Element { get { var boxedCell = ContentCell as INativeElementView; if (boxedCell == null) { throw new InvalidOperationException($"Implement {nameof(INativeElementView)} on cell renderer: {ContentCell.GetType().AssemblyQualifiedName}"); } return boxedCell.Element; } } public void Close() { _scroller.ContentOffset = new PointF(0, 0); } public override void LayoutSubviews() { base.LayoutSubviews(); if (_scroller == null || (_scroller != null && _scroller.Frame.Width == ContentView.Bounds.Width)) return; Update(_tableView, _cell, ContentCell); if (ContentCell is ViewCellRenderer.ViewTableCell && ContentCell.Subviews.Length > 0 && Math.Abs(ContentCell.Subviews[0].Frame.Height - Bounds.Height) > 1) { // Something goes weird inside iOS where LayoutSubviews wont get called when updating the bounds if the user // forces us to flip flop between a ContextActionCell and a normal cell in the middle of actually displaying the cell // so here we are going to hack it a forced update. Leave room for 1px of play because the border is 1 or .5px and must // be accounted for. // // Fixes https://bugzilla.xamarin.com/show_bug.cgi?id=39450 ContentCell.LayoutSubviews(); } } public void PrepareForDeselect() { ScrollDelegate.PrepareForDeselect(_scroller); } public override SizeF SizeThatFits(SizeF size) { return ContentCell.SizeThatFits(size); } public void Update(UITableView tableView, Cell cell, UITableViewCell nativeCell) { var parentListView = cell.RealParent as IListViewController; var recycling = parentListView != null && parentListView.CachingStrategy == ListViewCachingStrategy.RecycleElement; if (_cell != cell && recycling) { if (_cell != null) ((INotifyCollectionChanged)_cell.ContextActions).CollectionChanged -= OnContextItemsChanged; ((INotifyCollectionChanged)cell.ContextActions).CollectionChanged += OnContextItemsChanged; } var height = Frame.Height; var width = ContentView.Frame.Width; nativeCell.Frame = new RectangleF(0, 0, width, height); nativeCell.SetNeedsLayout(); var handler = new PropertyChangedEventHandler(OnMenuItemPropertyChanged); _tableView = tableView; SetupSelection(tableView); if (_cell != null) { if (!recycling) _cell.PropertyChanged -= OnCellPropertyChanged; if (_menuItems.Count > 0) { if (!recycling) ((INotifyCollectionChanged)_cell.ContextActions).CollectionChanged -= OnContextItemsChanged; foreach (var item in _menuItems) item.PropertyChanged -= handler; } _menuItems.Clear(); } _menuItems.AddRange(cell.ContextActions); _cell = cell; if (!recycling) { cell.PropertyChanged += OnCellPropertyChanged; ((INotifyCollectionChanged)_cell.ContextActions).CollectionChanged += OnContextItemsChanged; } var isOpen = false; if (_scroller == null) { _scroller = new UIScrollView(new RectangleF(0, 0, width, height)); _scroller.ScrollsToTop = false; _scroller.ShowsHorizontalScrollIndicator = false; _scroller.PreservesSuperviewLayoutMargins = true; ContentView.AddSubview(_scroller); } else { _scroller.Frame = new RectangleF(0, 0, width, height); isOpen = ScrollDelegate.IsOpen; for (var i = 0; i < _buttons.Count; i++) { var b = _buttons[i]; b.RemoveFromSuperview(); b.Dispose(); } _buttons.Clear(); ScrollDelegate.Unhook(_scroller); ScrollDelegate.Dispose(); } if (ContentCell != nativeCell) { if (ContentCell != null) { ContentCell.RemoveFromSuperview(); ContentCell = null; } ContentCell = nativeCell; //Hack: if we have a ImageCell the insets are slightly different, //the inset numbers user below were taken using the Reveal app from the default cells if ((ContentCell as CellTableViewCell)?.Cell is ImageCell) { nfloat imageCellInsetLeft = 57; nfloat imageCellInsetRight = 0; if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad) { imageCellInsetLeft = 89; imageCellInsetRight = imageCellInsetLeft / 2; } SeparatorInset = new UIEdgeInsets(0, imageCellInsetLeft, 0, imageCellInsetRight); } _scroller.AddSubview(nativeCell); } SetupButtons(width, height); UIView container = null; var totalWidth = width; for (var i = _buttons.Count - 1; i >= 0; i--) { var b = _buttons[i]; totalWidth += b.Frame.Width; if (UIDevice.CurrentDevice.CheckSystemVersion(8, 0)) _scroller.AddSubview(b); else { if (container == null) { container = new iOS7ButtonContainer(b.Frame.Width); _scroller.InsertSubview(container, 0); } container.AddSubview(b); } } _scroller.Delegate = new ContextScrollViewDelegate(container, _buttons, isOpen); _scroller.ContentSize = new SizeF(totalWidth, height); if (isOpen) _scroller.SetContentOffset(new PointF(ScrollDelegate.ButtonsWidth, 0), false); else _scroller.SetContentOffset(new PointF(0, 0), false); } protected override void Dispose(bool disposing) { if (disposing) { if (_scroller != null) { _scroller.Dispose(); _scroller = null; } _tableView = null; if (_moreButton != null) { _moreButton.Dispose(); _moreButton = null; } for (var i = 0; i < _buttons.Count; i++) _buttons[i].Dispose(); _buttons.Clear(); _menuItems.Clear(); if (_cell != null) { if (_cell.HasContextActions) ((INotifyCollectionChanged)_cell.ContextActions).CollectionChanged -= OnContextItemsChanged; _cell = null; } } base.Dispose(disposing); } void ActivateMore() { var displayed = new HashSet(); for (var i = 0; i < _buttons.Count; i++) { var tag = _buttons[i].Tag; if (tag >= 0) displayed.Add(tag); } var frame = _moreButton.Frame; var x = frame.X - _scroller.ContentOffset.X; var path = _tableView.IndexPathForCell(this); var rowPosition = _tableView.RectForRowAtIndexPath(path); var sourceRect = new RectangleF(x, rowPosition.Y, rowPosition.Width, rowPosition.Height); if (UIDevice.CurrentDevice.CheckSystemVersion(8, 0)) { var actionSheet = new MoreActionSheetController(); for (var i = 0; i < _cell.ContextActions.Count; i++) { if (displayed.Contains(i)) continue; var item = _cell.ContextActions[i]; var weakItem = new WeakReference(item); var action = UIAlertAction.Create(item.Text, UIAlertActionStyle.Default, a => { _scroller.SetContentOffset(new PointF(0, 0), true); MenuItem mi; if (weakItem.TryGetTarget(out mi)) ((IMenuItemController)mi).Activate(); }); actionSheet.AddAction(action); } var controller = GetController(); if (controller == null) throw new InvalidOperationException("No UIViewController found to present."); if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Phone) { var cancel = UIAlertAction.Create(StringResources.Cancel, UIAlertActionStyle.Cancel, null); actionSheet.AddAction(cancel); } else { actionSheet.PopoverPresentationController.SourceView = _tableView; actionSheet.PopoverPresentationController.SourceRect = sourceRect; } controller.PresentViewController(actionSheet, true, null); } else { var d = new MoreActionSheetDelegate { Scroller = _scroller, Items = new List() }; var actionSheet = new UIActionSheet(null, d); for (var i = 0; i < _cell.ContextActions.Count; i++) { if (displayed.Contains(i)) continue; var item = _cell.ContextActions[i]; d.Items.Add(item); actionSheet.AddButton(item.Text); } if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Phone) { var index = actionSheet.AddButton(StringResources.Cancel); actionSheet.CancelButtonIndex = index; } actionSheet.ShowFrom(sourceRect, _tableView, true); } } void CullButtons(nfloat acceptableTotalWidth, ref bool needMoreButton, ref nfloat largestButtonWidth) { while (largestButtonWidth * (_buttons.Count + (needMoreButton ? 1 : 0)) > acceptableTotalWidth && _buttons.Count > 1) { needMoreButton = true; var button = _buttons[_buttons.Count - 1]; _buttons.RemoveAt(_buttons.Count - 1); if (largestButtonWidth == button.Frame.Width) largestButtonWidth = GetLargestWidth(); } if (needMoreButton && _cell.ContextActions.Count - _buttons.Count == 1) _buttons.RemoveAt(_buttons.Count - 1); } UIButton GetButton(MenuItem item) { var button = new UIButton(new RectangleF(0, 0, 1, 1)); if (!item.IsDestructive) button.SetBackgroundImage(NormalBackground, UIControlState.Normal); else button.SetBackgroundImage(DestructiveBackground, UIControlState.Normal); button.SetTitle(item.Text, UIControlState.Normal); button.TitleEdgeInsets = new UIEdgeInsets(0, 15, 0, 15); button.Enabled = ((IMenuItemController)item).IsEnabled; return button; } UIViewController GetController() { Element e = _cell; while (e.RealParent != null) { var renderer = Platform.GetRenderer((VisualElement)e.RealParent); if (renderer.ViewController != null) return renderer.ViewController; e = e.RealParent; } return null; } nfloat GetLargestWidth() { nfloat largestWidth = 0; for (var i = 0; i < _buttons.Count; i++) { var frame = _buttons[i].Frame; if (frame.Width > largestWidth) largestWidth = frame.Width; } return largestWidth; } void OnButtonActivated(object sender, EventArgs e) { var button = (UIButton)sender; if (button.Tag == -1) ActivateMore(); else { _scroller.SetContentOffset(new PointF(0, 0), true); ((IMenuItemController)_cell.ContextActions[(int)button.Tag]).Activate(); } } void OnCellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == "HasContextActions") { var parentListView = _cell.RealParent as IListViewController; var recycling = parentListView != null && parentListView.CachingStrategy == ListViewCachingStrategy.RecycleElement; if (!recycling) ReloadRow(); } } void OnContextItemsChanged(object sender, NotifyCollectionChangedEventArgs e) { var parentListView = _cell.RealParent as IListViewController; var recycling = parentListView != null && parentListView.CachingStrategy == ListViewCachingStrategy.RecycleElement; if (recycling) Update(_tableView, _cell, ContentCell); else ReloadRow(); // TODO: Perhaps make this nicer if it's open while adding } void OnMenuItemPropertyChanged(object sender, PropertyChangedEventArgs e) { var parentListView = _cell.RealParent as IListViewController; var recycling = parentListView != null && parentListView.CachingStrategy == ListViewCachingStrategy.RecycleElement; if (recycling) Update(_tableView, _cell, ContentCell); else ReloadRow(); } void ReloadRow() { if (_scroller.ContentOffset.X > 0) { ((ContextScrollViewDelegate)_scroller.Delegate).ClosedCallback = () => { ReloadRowCore(); ((ContextScrollViewDelegate)_scroller.Delegate).ClosedCallback = null; }; _scroller.SetContentOffset(new PointF(0, 0), true); } else ReloadRowCore(); } void ReloadRowCore() { if (_cell.RealParent == null) return; var path = _cell.GetIndexPath(); var selected = path.Equals(_tableView.IndexPathForSelectedRow); _tableView.ReloadRows(new[] { path }, UITableViewRowAnimation.None); if (selected) { _tableView.SelectRow(path, false, UITableViewScrollPosition.None); _tableView.Source.RowSelected(_tableView, path); } } UIView SetupButtons(nfloat width, nfloat height) { MenuItem destructive = null; nfloat largestWidth = 0, acceptableSize = width * 0.80f; for (var i = 0; i < _cell.ContextActions.Count; i++) { var item = _cell.ContextActions[i]; if (_buttons.Count == 3) { if (destructive != null) break; if (!item.IsDestructive) continue; _buttons.RemoveAt(_buttons.Count - 1); } if (item.IsDestructive) destructive = item; var button = GetButton(item); button.Tag = i; var buttonWidth = button.TitleLabel.SizeThatFits(new SizeF(width, height)).Width + 30; if (buttonWidth > largestWidth) largestWidth = buttonWidth; if (destructive == item) _buttons.Insert(0, button); else _buttons.Add(button); } var needMore = _cell.ContextActions.Count > _buttons.Count; if (_cell.ContextActions.Count > 2) CullButtons(acceptableSize, ref needMore, ref largestWidth); var resize = false; if (needMore) { if (largestWidth * 2 > acceptableSize) { largestWidth = acceptableSize / 2; resize = true; } var button = new UIButton(new RectangleF(0, 0, largestWidth, height)); button.SetBackgroundImage(NormalBackground, UIControlState.Normal); button.TitleEdgeInsets = new UIEdgeInsets(0, 15, 0, 15); button.SetTitle(StringResources.More, UIControlState.Normal); var moreWidth = button.TitleLabel.SizeThatFits(new SizeF(width, height)).Width + 30; if (moreWidth > largestWidth) { largestWidth = moreWidth; CullButtons(acceptableSize, ref needMore, ref largestWidth); if (largestWidth * 2 > acceptableSize) { largestWidth = acceptableSize / 2; resize = true; } } button.Tag = -1; button.TouchUpInside += OnButtonActivated; if (resize) button.TitleLabel.AdjustsFontSizeToFitWidth = true; _moreButton = button; _buttons.Add(button); } var handler = new PropertyChangedEventHandler(OnMenuItemPropertyChanged); var totalWidth = _buttons.Count * largestWidth; for (var n = 0; n < _buttons.Count; n++) { var b = _buttons[n]; if (b.Tag >= 0) { var item = _cell.ContextActions[(int)b.Tag]; item.PropertyChanged += handler; } var offset = (n + 1) * largestWidth; var x = width - offset; if (UIDevice.CurrentDevice.CheckSystemVersion(8, 0)) x += totalWidth; b.Frame = new RectangleF(x, 0, largestWidth, height); if (resize) b.TitleLabel.AdjustsFontSizeToFitWidth = true; b.SetNeedsLayout(); if (b != _moreButton) b.TouchUpInside += OnButtonActivated; } return null; } void SetupSelection(UITableView table) { for (var i = 0; i < table.GestureRecognizers.Length; i++) { var r = table.GestureRecognizers[i] as SelectGestureRecognizer; if (r != null) return; } _tableView.AddGestureRecognizer(new SelectGestureRecognizer()); } class SelectGestureRecognizer : UITapGestureRecognizer { NSIndexPath _lastPath; public SelectGestureRecognizer() : base(Tapped) { ShouldReceiveTouch = (recognizer, touch) => { var table = (UITableView)View; var pos = touch.LocationInView(table); _lastPath = table.IndexPathForRowAtPoint(pos); if (_lastPath == null) return false; var cell = table.CellAt(_lastPath) as ContextActionsCell; return cell != null; }; } static void Tapped(UIGestureRecognizer recognizer) { var selector = (SelectGestureRecognizer)recognizer; if (selector._lastPath == null) return; var table = (UITableView)recognizer.View; if (!selector._lastPath.Equals(table.IndexPathForSelectedRow)) table.SelectRow(selector._lastPath, false, UITableViewScrollPosition.None); table.Source.RowSelected(table, selector._lastPath); } } class MoreActionSheetController : UIAlertController { public override UIAlertControllerStyle PreferredStyle { get { return UIAlertControllerStyle.ActionSheet; } } public override void WillRotate(UIInterfaceOrientation toInterfaceOrientation, double duration) { DismissViewController(false, null); } } class MoreActionSheetDelegate : UIActionSheetDelegate { public List Items; public UIScrollView Scroller; public override void Clicked(UIActionSheet actionSheet, nint buttonIndex) { if (buttonIndex == Items.Count) return; // Cancel button Scroller.SetContentOffset(new PointF(0, 0), true); // do not activate a -1 index when dismissing by clicking outside the popover if (buttonIndex >= 0) ((IMenuItemController)Items[(int)buttonIndex]).Activate(); } } } }