diff --git a/Eureka.xcodeproj/project.pbxproj b/Eureka.xcodeproj/project.pbxproj index ce7fa53b5..cab49d277 100644 --- a/Eureka.xcodeproj/project.pbxproj +++ b/Eureka.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 49ADC7F91C8A83240073952B /* StepperRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49ADC7F81C8A83240073952B /* StepperRow.swift */; }; 51729DF61B9A4F5E004A00EB /* Eureka.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51729DEB1B9A4F5E004A00EB /* Eureka.framework */; }; 51729E671B9A5FA5004A00EB /* Eureka.h in Headers */ = {isa = PBXBuildFile; fileRef = 51729E661B9A5FA5004A00EB /* Eureka.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 798C5ADA1EF1E35600A21F52 /* SwipeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798C5AD91EF1E35600A21F52 /* SwipeActions.swift */; }; 8690E4BE1CFDE062004CDB1C /* Eureka.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 8690E4BD1CFDE062004CDB1C /* Eureka.bundle */; }; B257FE2E1EC0F66900043911 /* RowsInsertionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B257FE2D1EC0F66900043911 /* RowsInsertionTests.swift */; }; B2A401161EC0BA140042EDF0 /* SectionsInsertionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2A401151EC0BA140042EDF0 /* SectionsInsertionTests.swift */; }; @@ -179,6 +180,7 @@ 51729DF51B9A4F5E004A00EB /* EurekaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EurekaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 51729DFC1B9A4F5E004A00EB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Tests/Info.plist; sourceTree = ""; }; 51729E661B9A5FA5004A00EB /* Eureka.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Eureka.h; path = Source/Eureka.h; sourceTree = SOURCE_ROOT; }; + 798C5AD91EF1E35600A21F52 /* SwipeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActions.swift; sourceTree = ""; }; 8690E4BD1CFDE062004CDB1C /* Eureka.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Eureka.bundle; sourceTree = ""; }; B257FE2D1EC0F66900043911 /* RowsInsertionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RowsInsertionTests.swift; path = Tests/RowsInsertionTests.swift; sourceTree = SOURCE_ROOT; }; B2A401151EC0BA140042EDF0 /* SectionsInsertionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SectionsInsertionTests.swift; path = Tests/SectionsInsertionTests.swift; sourceTree = SOURCE_ROOT; }; @@ -225,6 +227,7 @@ 28EEBFF71C7E241200300699 /* SelectableRowType.swift */, 2859CDBF1C7D138D0002982F /* Section.swift */, 2859CE231C7E141B0002982F /* SelectableSection.swift */, + 798C5AD91EF1E35600A21F52 /* SwipeActions.swift */, 28EE0FDD1D5E889F00B91340 /* Validation.swift */, ); name = Core; @@ -530,6 +533,7 @@ 49ADC7F91C8A83240073952B /* StepperRow.swift in Sources */, 28EE46AC1C7F712300EFF4A2 /* DateInlineRow.swift in Sources */, 28EEC01C1C7E3A7400300699 /* ButtonRowWithPresent.swift in Sources */, + 798C5ADA1EF1E35600A21F52 /* SwipeActions.swift in Sources */, 28EEBFF41C7E240000300699 /* RowProtocols.swift in Sources */, 28EEBFFE1C7E281F00300699 /* CheckRow.swift in Sources */, 2859CDC41C7D19C50002982F /* HeaderFooterView.swift in Sources */, diff --git a/Example/Example/Base.lproj/Main.storyboard b/Example/Example/Base.lproj/Main.storyboard index 3fa40fa8d..c84f6c7c1 100644 --- a/Example/Example/Base.lproj/Main.storyboard +++ b/Example/Example/Base.lproj/Main.storyboard @@ -172,6 +172,7 @@ + @@ -264,6 +265,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Example/Example/ViewController.swift b/Example/Example/ViewController.swift index ed7dfcd61..6af807091 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -106,14 +106,10 @@ class HomeViewController : FormViewController { row.title = row.tag row.presentationMode = .segueName(segueName: "MultivaluedSectionsControllerSegue", onDismiss: nil) } - - - +++ Section() - <<< ButtonRow() { (row: ButtonRow) -> Void in - row.title = "About" - } - .onCellSelection { [weak self] (cell, row) in - self?.showAlert() + + <<< ButtonRow("Swipe Actions") { (row: ButtonRow) -> Void in + row.title = row.tag + row.presentationMode = .segueName(segueName: "SwipeActionsControllerSegue", onDismiss: nil) } } @@ -1747,6 +1743,58 @@ class MultivaluedOnlyDeleteController: FormViewController { } } +class SwipeActionsController: FormViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + form +++ Section(footer: "Eureka sets table.isEditing = false for SwipeActions.\n\nMultivaluedSections need table.isEditing = true, therefore both can't be used on the same view.") + <<< LabelRow("Actions Right: iOS >= 7") { + $0.title = $0.tag + + let moreAction = SwipeAction(style: .normal, title: "More", handler: { (action, row, completionHandler) in + print("More") + completionHandler?(true) + }) + + let deleteAction = SwipeAction(style: .destructive, title: "Delete", handler: { (action, row, completionHandler) in + print("Delete") + completionHandler?(true) + }) + + $0.trailingSwipe.actions = [deleteAction,moreAction] + } + + <<< LabelRow("Actions Left & Right: iOS >= 11") { + $0.title = $0.tag + + let moreAction = SwipeAction(style: .normal, title: "More", handler: { (action, row, completionHandler) in + print("More") + completionHandler?(true) + }) + + let deleteAction = SwipeAction(style: .destructive, title: "Delete", handler: { (action, row, completionHandler) in + print("Delete") + completionHandler?(true) + }) + + $0.trailingSwipe.actions = [deleteAction,moreAction] + $0.trailingSwipe.performsFirstActionWithFullSwipe = true + + if #available(iOS 11,*) { + let infoAction = SwipeAction(style: .normal, title: "Info", handler: { (action, row, completionHandler) in + print("Info") + completionHandler?(true) + }) + infoAction.backgroundColor = .blue + + $0.leadingSwipe.actions = [infoAction] + $0.leadingSwipe.performsFirstActionWithFullSwipe = true + } + } + } +} + class EurekaLogoViewNib: UIView { @IBOutlet weak var imageView: UIImageView! diff --git a/README.md b/README.md index bd9b6b1f4..57258b967 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Made with ❤️ by [XMARTLABS](http://xmartlabs.com). This is the re-creation o + [List sections] + [Multivalued sections] + [Validations] + + [Swipe Actions] * [Custom rows] + [Basic custom rows] + [Custom inline rows] @@ -581,6 +582,46 @@ Each row has the `validationErrors` property that can be used to retrieve all va As expected, the Rules must use the same types as the Row object. Be extra careful to check the row type used. You might see a compiler error ("Incorrect arugment label in call (have 'rule:' expected 'ruleSet:')" that is not pointing to the problem when mixing types. +### Swipe Actions + +Eureka 4.1.0 introduces the swipe feature. + +You are now able to define multiple `leadingSwipe` and `trailingSwipe` actions per row. As swipe actions depend on iOS system features, `leadingSwipe` is available on iOS 11.0+ only. + +Let's see how to define swipe actions. + +```swift +let row = TextRow() { + let deleteAction = SwipeAction( + style: .destructive, + title: "Delete", + handler: { (action, row, completionHandler) in + //add your code here. + //make sure you call the completionHandler once done. + completionHandler?(true) + }) + deleteAction.image = UIImage(named: "icon-trash") + + $0.trailingSwipe.actions = [deleteAction] + $0.trailingSwipe.performsFirstActionWithFullSwipe = true + + //please be aware: `leadingSwipe` is only available on iOS 11+ only + let infoAction = SwipeAction( + style: .normal, + title: "Info", + handler: { (action, row, completionHandler) in + //add your code here. + //make sure you call the completionHandler once done. + completionHandler?(true) + }) + infoAction.backgroundColor = .blue + infoAction.image = UIImage(named: "icon-info") + + $0.leadingSwipe.actions = [infoAction] + $0.leadingSwipe.performsFirstActionWithFullSwipe = true + } +``` + ## Custom rows It is very common that you need a row that is different from those included in Eureka. If this is the case you will have to create your own row but this should not be difficult. You can read [this tutorial on how to create custom rows](https://blog.xmartlabs.com/2016/09/06/Eureka-custom-row-tutorial/) to get started. You might also want to have a look at [EurekaCommunity] which includes some extra rows ready to be added to Eureka. @@ -1116,6 +1157,7 @@ It's up to you to decide if you want to use Eureka custom operators or not. [List sections]: #list-sections [Multivalued sections]: #multivalued-sections [Validations]: #validations +[Swipe Actions]: #swipe-actions [CustomCellsController]: Example/Example/ViewController.swift diff --git a/Source/Core/BaseRow.swift b/Source/Core/BaseRow.swift index 747a805df..14f6fb508 100644 --- a/Source/Core/BaseRow.swift +++ b/Source/Core/BaseRow.swift @@ -99,6 +99,17 @@ open class BaseRow: BaseRowType { /// The section to which this row belongs. public weak var section: Section? + + public lazy var trailingSwipe = SwipeConfiguration(self) + + //needs the accessor because if marked directly this throws "Stored properties cannot be marked potentially unavailable with '@available'" + private lazy var _leadingSwipe = SwipeConfiguration(self) + + @available(iOS 11,*) + public var leadingSwipe: SwipeConfiguration{ + get{ return self._leadingSwipe } + set{ self._leadingSwipe = newValue } + } public required init(tag: String? = nil) { self.tag = tag diff --git a/Source/Core/Core.swift b/Source/Core/Core.swift index 6ba33d8a7..f30f5545f 100644 --- a/Source/Core/Core.swift +++ b/Source/Core/Core.swift @@ -462,9 +462,7 @@ open class FormViewController: UIViewController, FormViewControllerProtocol, For tableView.dataSource = self } tableView.estimatedRowHeight = BaseRow.estimatedRowHeight - - tableView.setEditing(true, animated: false) - tableView.allowsSelectionDuringEditing = true + tableView.allowsSelectionDuringEditing = true } open override func viewWillAppear(_ animated: Bool) { @@ -502,6 +500,10 @@ open class FormViewController: UIViewController, FormViewControllerProtocol, For NotificationCenter.default.addObserver(self, selector: #selector(FormViewController.keyboardWillShow(_:)), name: Notification.Name.UIKeyboardWillShow, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(FormViewController.keyboardWillHide(_:)), name: Notification.Name.UIKeyboardWillHide, object: nil) + + if form.containsMultivaluedSection{ + tableView.setEditing(true, animated: false) + } } open override func viewWillDisappear(_ animated: Bool) { @@ -789,8 +791,13 @@ extension FormViewController : UITableViewDelegate { } open func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - guard let section = form[indexPath.section] as? MultivaluedSection else { return false } - let row = form[indexPath] + let row = form[indexPath] + if row.trailingSwipe.actions.count > 0{ + return true + } else if #available(iOS 11,*), row.leadingSwipe.actions.count > 0{ + return true + } + guard let section = form[indexPath.section] as? MultivaluedSection else { return false } guard !row.isDisabled else { return false } guard !(indexPath.row == section.count - 1 && section.multivaluedOptions.contains(.Insert) && section.showInsertIconInAddButton) else { return true @@ -849,12 +856,12 @@ extension FormViewController : UITableViewDelegate { open func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { guard let section = form[sourceIndexPath.section] as? MultivaluedSection else { return sourceIndexPath } guard sourceIndexPath.section == proposedDestinationIndexPath.section else { return sourceIndexPath } - + let destRow = form[proposedDestinationIndexPath] if destRow is BaseInlineRowType && destRow._inlineRow != nil { return IndexPath(row: proposedDestinationIndexPath.row + (sourceIndexPath.row < proposedDestinationIndexPath.row ? 1 : -1), section:sourceIndexPath.section) } - + if proposedDestinationIndexPath.row > 0 { let previousRow = form[IndexPath(row: proposedDestinationIndexPath.row - 1, section: proposedDestinationIndexPath.section)] if previousRow is BaseInlineRowType && previousRow._inlineRow != nil { @@ -871,7 +878,7 @@ extension FormViewController : UITableViewDelegate { guard var section = form[sourceIndexPath.section] as? MultivaluedSection else { return } if sourceIndexPath.row < section.count && destinationIndexPath.row < section.count && sourceIndexPath.row != destinationIndexPath.row { - + let sourceRow = form[sourceIndexPath] animateTableView = false section.remove(at: sourceIndexPath.row) @@ -884,6 +891,9 @@ extension FormViewController : UITableViewDelegate { open func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCellEditingStyle { guard let section = form[indexPath.section] as? MultivaluedSection else { + if form[indexPath].trailingSwipe.actions.count > 0{ + return .delete + } return .none } if section.multivaluedOptions.contains(.Insert) && indexPath.row == section.count - 1 { @@ -898,6 +908,20 @@ extension FormViewController : UITableViewDelegate { open func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { return self.tableView(tableView, editingStyleForRowAt: indexPath) != .none } + + @available(iOS 11,*) + public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + return form[indexPath].leadingSwipe.contextualConfiguration + } + + @available(iOS 11,*) + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + return form[indexPath].trailingSwipe.contextualConfiguration + } + + public func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]?{ + return form[indexPath].trailingSwipe.contextualActions as? [UITableViewRowAction] + } } extension FormViewController : UITableViewDataSource { diff --git a/Source/Core/Form.swift b/Source/Core/Form.swift index d78869540..97559938e 100644 --- a/Source/Core/Form.swift +++ b/Source/Core/Form.swift @@ -352,6 +352,12 @@ extension Form { } kvoWrapper.sections.insert(section, at: formIndex == NSNotFound ? 0 : formIndex + 1 ) } + + var containsMultivaluedSection: Bool{ + return kvoWrapper.sections.contains { (section) -> Bool in + return section is MultivaluedSection + } + } func getValues(for rows: [BaseRow]) -> [String: Any?] { return rows.reduce([String: Any?]()) { diff --git a/Source/Core/Section.swift b/Source/Core/Section.swift index 27395dbb1..a57e257b7 100644 --- a/Source/Core/Section.swift +++ b/Source/Core/Section.swift @@ -455,7 +455,7 @@ open class MultivaluedSection: Section { let addRow = addButtonProvider(self) addRow.onCellSelection { cell, row in guard let tableView = cell.formViewController()?.tableView, let indexPath = row.indexPath else { return } - cell.formViewController()?.tableView(tableView, commit: .insert, forRowAt: indexPath) + cell.formViewController()?.tableView(tableView, commit: .insert, forRowAt: indexPath) } self <<< addRow } diff --git a/Source/Core/SelectableSection.swift b/Source/Core/SelectableSection.swift index 39c06772e..92b31bd1c 100644 --- a/Source/Core/SelectableSection.swift +++ b/Source/Core/SelectableSection.swift @@ -84,7 +84,9 @@ extension SelectableSectionType where Self: Section { for row in rows { if let row = row as? SelectableRow { row.onCellSelection { [weak self] cell, row in - guard let s = self, !row.isDisabled else { return } + guard let s = self, !row.isDisabled else { + return + } switch s.selectionType { case .multipleSelection: row.value = row.value == nil ? row.selectableValue : nil diff --git a/Source/Core/SwipeActions.swift b/Source/Core/SwipeActions.swift new file mode 100644 index 000000000..997ce3ad5 --- /dev/null +++ b/Source/Core/SwipeActions.swift @@ -0,0 +1,125 @@ +// +// Swipe.swift +// Eureka +// +// Created by Marco Betschart on 14.06.17. +// Copyright © 2017 Xmartlabs. All rights reserved. +// + +import Foundation + +public typealias SwipeActionHandler = (ContextualAction, BaseRow, ((Bool) -> Void)?) -> Void + +public class SwipeAction: ContextualAction { + let handler: SwipeActionHandler + let style: Style + + weak var row: BaseRow! + + public var backgroundColor: UIColor? + public var image: UIImage? + public var title: String? + + public init(style: Style, title: String?, handler: @escaping SwipeActionHandler){ + self.style = style + self.title = title + self.handler = handler + } + + var contextualAction: ContextualAction{ + var action: ContextualAction + + if #available(iOS 11, *){ + action = UIContextualAction(style: style.contextualStyle as! UIContextualAction.Style, title: title){ [weak self] action, view, completion -> Void in + guard let strongSelf = self else{ return } + strongSelf.handler(action, strongSelf.row, completion) + } + + } else { + action = UITableViewRowAction(style: style.contextualStyle as! UITableViewRowActionStyle,title: title){ [weak self] (action, indexPath) -> Void in + guard let strongSelf = self else{ return } + strongSelf.handler(action, strongSelf.row, nil) + } + } + + action.backgroundColor = self.backgroundColor ?? action.backgroundColor + action.image = self.image ?? action.image + + return action + } + + public enum Style: Int{ + case normal = 0 + case destructive = 1 + + var contextualStyle: ContextualStyle{ + if #available(iOS 11, *){ + switch self{ + case .normal: + return UIContextualAction.Style.normal + case .destructive: + return UIContextualAction.Style.destructive + } + + } else { + switch self{ + case .normal: + return UITableViewRowActionStyle.normal + case .destructive: + return UITableViewRowActionStyle.destructive + } + } + } + } +} + +public struct SwipeConfiguration { + weak var row: BaseRow! + + init(_ row: BaseRow){ + self.row = row + } + + public var performsFirstActionWithFullSwipe = false + public var actions: [SwipeAction] = []{ + willSet{ + for action in newValue{ + action.row = self.row + } + } + } + + @available(iOSApplicationExtension 11.0, *) + public var contextualConfiguration: UISwipeActionsConfiguration?{ + let contextualConfiguration = UISwipeActionsConfiguration(actions: self.contextualActions as! [UIContextualAction]) + contextualConfiguration.performsFirstActionWithFullSwipe = self.performsFirstActionWithFullSwipe + + return contextualConfiguration + } + + public var contextualActions: [ContextualAction]{ + return self.actions.map{ $0.contextualAction } + } +} + +public protocol ContextualAction { + var backgroundColor: UIColor?{ get set } + var image: UIImage?{ get set } + var title: String?{ get set } +} + +extension UITableViewRowAction: ContextualAction { + public var image: UIImage? { + get { return nil } + set { return } + } +} + +@available(iOSApplicationExtension 11.0, *) +extension UIContextualAction: ContextualAction{} + +public protocol ContextualStyle{} +extension UITableViewRowActionStyle: ContextualStyle{} + +@available(iOSApplicationExtension 11.0, *) +extension UIContextualAction.Style: ContextualStyle{} diff --git a/Tests/SelectableSectionTests.swift b/Tests/SelectableSectionTests.swift index 120b59c79..2fa1bfceb 100644 --- a/Tests/SelectableSectionTests.swift +++ b/Tests/SelectableSectionTests.swift @@ -81,23 +81,24 @@ class SelectableSectionTests: XCTestCase { let value1 = (formVC.form[0] as! SelectableSection>).selectedRow()?.baseValue let value2 = (formVC.form[1] as! SelectableSection>).selectedRows().map {$0.baseValue} - XCTAssertEqual(value1 as? String, "Antarctica") + XCTAssertEqual(value1 as! String, "Antarctica") XCTAssertTrue(value2.count == 2) - XCTAssertEqual((value2[0] as? String), "Atlantic") - XCTAssertEqual((value2[1] as? String), "Pacific") + XCTAssertEqual((value2[0] as! String), "Atlantic") + XCTAssertEqual((value2[1] as! String), "Pacific") //Now deselect One of the multiple selection section and change the value of the first section formVC.tableView(formVC.tableView!, didSelectRowAt: IndexPath(row: 6, section: 0)) formVC.tableView(formVC.tableView!, didSelectRowAt: IndexPath(row: 1, section: 1)) + let value3 = (formVC.form[0] as! SelectableSection>).selectedRow()?.baseValue let selectedRows = (formVC.form[1] as! SelectableSection>).selectedRows() let value4 = selectedRows.map { $0.baseValue } - XCTAssertEqual(value3 as? String, "South America") + XCTAssertEqual(value3 as! String, "South America") XCTAssertTrue(value4.count == 1) - XCTAssertEqual((value4[0] as? String), "Pacific") + XCTAssertEqual((value4[0] as! String), "Pacific") } func testDeselectionDisabled() {