{"id":6091,"date":"2022-06-14T17:13:37","date_gmt":"2022-06-14T09:13:37","guid":{"rendered":"http:\/\/123.57.164.21\/?p=6091"},"modified":"2022-06-14T17:13:37","modified_gmt":"2022-06-14T09:13:37","slug":"swipe-actions-in-swiftui-3","status":"publish","type":"post","link":"https:\/\/92it.top\/?p=6091","title":{"rendered":"Swipe Actions in SwiftUI 3"},"content":{"rendered":"\n<p>In this part of The Ultimate Guide to List Views, we will look at Swipe Actions. Swipe Actions are used in many apps, most prominently in Apple\u2019s own Mail app. They provide a well-known and easy-to-use UI affordance to allow users to perform actions on list items.<\/p>\n\n\n\n<p>UIKit has supported Swipe Actions since iOS 11, but SwiftUI didn\u2019t support Swipe Actions until WWDC 2021.<\/p>\n\n\n\n<p>In this post, we will look at the following features:<\/p>\n\n\n\n<ul><li><strong>Swipe-to-delete<\/strong> using the <code>onDelete<\/code> modifier<\/li><li><strong>Deleting and moving items<\/strong> using <code>EditButton<\/code> and the <code>.editMode<\/code> environmental value<\/li><li>Using <strong>Swipe Actions<\/strong> (this is the most flexible approach, which also gives us a wealth of styling options)<\/li><\/ul>\n\n\n\n<p><strong>Swipe-to-delete<\/strong><\/p>\n\n\n\n<p>This feature was available in SwiftUI right from the beginning. It is pretty straight-forward to use, but also pretty basic (or rather inflexible). To add swipe-to-delete to a <code>List<\/code>view, all you need to do is apply the <code>onDelete<\/code> modifier to a <code>ForEach<\/code> loop inside a <code>List<\/code> view. This modifier expects a closure with one parameter that contains an <code>IndexSet<\/code>, indicating which rows to delete.<\/p>\n\n\n\n<p>Here is a code snippet that shows a simple <code>List<\/code> with an <code>onDelete<\/code> modifier. When the user swipes to delete, the closure will be called, which will consequently remove the respective row from the array of items backing the <code>List<\/code> view:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">struct SwipeToDeleteListView: View {\n  @State fileprivate var items = [\n    Item(title: \"Puzzle\", iconName: \"puzzlepiece\", badge: \"Nice!\"),\n    Item(title: \"Controller\", iconName: \"gamecontroller\", badge: \"Clicky!\"),\n    Item(title: \"Shopping cart\", iconName: \"cart\", badge: \"$$$\"),\n    Item(title: \"Gift\", iconName: \"giftcard\", badge: \":-)\"),\n    Item(title: \"Clock\", iconName: \"clock\", badge: \"Tick tock\"),\n    Item(title: \"People\", iconName: \"person.2\", badge: \"2\"),\n    Item(title: \"T-Shirt\", iconName: \"tshirt\", badge: \"M\")\n  ]\n\n  var body: some View {\n    List {\n      ForEach(items) { item in\n        Label(item.title, systemImage: item.iconName)\n      }\n      .onDelete { indexSet in\n        items.remove(atOffsets: indexSet)\n      }\n    }\n  }\n}<\/pre>\n\n\n\n<p>It\u2019s actually quite convenient that <code>onDelete<\/code> passes an <code>IndexSet<\/code> to indicate which item(s) should be deleted, as <code>Array<\/code> provides a method <code>remove(atOffsets:)<\/code> that takes an <code>IndexSet<\/code>.<\/p>\n\n\n\n<p>It is worth noting that you cannot apply <code>onDelete<\/code> to <code>List<\/code> directly &#8211; you need to use a <code>ForEach<\/code> loop instead and nest it inside a <code>List<\/code>. I am not entirely sure why the SwiftUI team decided to implement it this way &#8211; if you have any clue (or work on the SwiftUI team), please get in touch with me!<\/p>\n\n\n\n<p><strong>Moving and Deleting items using EditMode<\/strong><\/p>\n\n\n\n<p>For some applications, it makes sense to let users rearrange items by dragging them across the list. SwiftUI makes implementing this super easy &#8211; all you need to do is apply the <code>onMove<\/code> view modifier to a <code>List<\/code> and then update the underlying data structure accordingly.<\/p>\n\n\n\n<p>Here is a snippet that shows how to implement this for a simple array:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List {\n  ForEach(items) { item in\n    Label(item.title, systemImage: item.iconName)\n  }\n  .onDelete { indexSet in\n    items.remove(atOffsets: indexSet)\n  }\n  .onMove { indexSet, index in\n    items.move(fromOffsets: indexSet, toOffset: index)\n  }\n}<\/pre>\n\n\n\n<p>Again, this is made easy thanks to <code>Array.move<\/code>, which expects exactly the parameters that we receive in <code>onDelete<\/code>\u2019s closure.<\/p>\n\n\n\n<p>To turn on edit mode for a <code>List<\/code>, there are two options:<\/p>\n\n\n\n<ul><li>Using the <code>.editMode<\/code> environment value<\/li><li>Using the <code>EditButton<\/code> view<\/li><\/ul>\n\n\n\n<p>Under the hood, both approaches make use of SwiftUI\u2019s environment. The following snippet demonstrates how to use the <code>EditButton<\/code> to allow the user to turn on edit mode for the list:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List {\n  ForEach(items) { item in\n    Label(item.title, systemImage: item.iconName)\n  }\n  .onDelete { indexSet in\n    items.remove(atOffsets: indexSet)\n  }\n  .onMove { indexSet, index in\n    items.move(fromOffsets: indexSet, toOffset: index)\n  }\n}\n.toolbar {\n  EditButton()\n}<\/pre>\n\n\n\n<p id=\"swipe-actions\"><strong>Swipe Actions<\/strong><\/p>\n\n\n\n<p>For anything that goes beyond swipe-to-delete and <code>EditMode<\/code>, SwiftUI now supports Swipe Actions. This new API gives us a lot of control over how to display swipe actions:<\/p>\n\n\n\n<ul><li>We can define different swipe actions per row<\/li><li>We can specify the text, icon and tint color to use for each individual action<\/li><li>We can add add actions to the leading and trailing edge of a row<\/li><li>We can enable of disable full swipe for the first action on either end of the row, allowing users to trigger the action by completely swiping the row to the respective edge<\/li><\/ul>\n\n\n\n<p><strong>Basic Swipe Actions<\/strong><\/p>\n\n\n\n<p>Let\u2019s look at a simple example how to use this new API. To register a Swipe Action for a <code>List<\/code> row, we need to call the <code>swipeActions<\/code> view modifier. Inside the closure of the view modifier, we can set up one (or more) <code>Button<\/code>s to implement the action itself.<\/p>\n\n\n\n<p>The following code snippet demonstrates how to add a simple swipe action to a <code>List<\/code> view:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List(viewModel.items) { item in\n  Text(item.title)\n    .fontWeight(item.isRead ? .regular : .bold)\n    .swipeActions {\n      Button (action: { viewModel.markItemRead(item) }) {\n        if let isRead = item.isRead, isRead == true {\n          Label(\"Read\", systemImage: \"envelope.badge.fill\")\n        }\n        else {\n          Label(\"Unread\", systemImage: \"envelope.open.fill\")\n        }\n      }\n      .tint(.blue)\n    }\n}<\/pre>\n\n\n\n<p>It\u2019s worth noting that the <code>swipeActions<\/code> modifier is invoked on the view that represents the row. In this case, it is a simple <code>Text<\/code> view, but for more advanced lists, this might as well be a <code>HStack<\/code> or <code>VStack<\/code>. This is different from the <code>onDelete<\/code> modifier (which needs to be applied to a <code>ForEach<\/code> loop inside a <code>List<\/code> view) and it gives us the flexibility to apply a different set of actions depending on the row.<\/p>\n\n\n\n<p>Also note that each swipe action is represented by a <code>Button<\/code>. If you use any other view, SwiftUI will not register it, and no action will be shown. Likewise, if you try to apply the <code>swipeActions<\/code> modifier to the <code>List<\/code> or a <code>ForEach<\/code> loop, the modifier will be ignored.<\/p>\n\n\n\n<p><strong>Specifying the edge<\/strong><\/p>\n\n\n\n<p>By default, swipe actions will be added to the trailing edge of the row. This is why, in the previous example, the <em>mark as read\/unread<\/em> action was added to the trailing edge. To add the action to the leading edge (just like in Apple\u2019s Mail app), all we have to do is specify the <code>edge<\/code> parameter, like so:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List(viewModel.items) { item in\n  Text(item.title)\n    .fontWeight(item.isRead ? .regular : .bold)\n    .swipeActions(edge: .leading) {\n      Button (action: { viewModel.markItemRead(item) }) {\n       if let isRead = item.isRead, isRead == true {\n         Label(\"Read\", systemImage: \"envelope.badge.fill\")\n       }\n       else {\n         Label(\"Unread\", systemImage: \"envelope.open.fill\")\n       }\n     }\n   .tint(.blue)\n  }<\/pre>\n\n\n\n<p>To add actions to either edge, we can call the <code>swipeActions<\/code> modifier multiple times, specifying the edge we want to add the actions to.<\/p>\n\n\n\n<p>If you add swipe actions to both the leading and trailing edge, it is a good idea to be explicit about where you want to add the actions. In the following code snippet, we add add one action to the leading edge, and another one to the trailing edge.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List(viewModel.items) { item in\n  Text(item.title)\n    .fontWeight(item.isRead ? .regular : .bold)\n    .swipeActions(edge: .leading) {\n      Button (action: { viewModel.markItemRead(item) }) {\n        if let isRead = item.isRead, isRead == true {\n          Label(\"Read\", systemImage: \"envelope.badge.fill\")\n        }\n        else {\n          Label(\"Unread\", systemImage: \"envelope.open.fill\")\n        }\n      }\n      .tint(.blue)\n    }\n    .swipeActions(edge: .trailing) {\n      Button(role: .destructive, action: { viewModel.deleteItem(item) } ) {\n        Label(\"Delete\", systemImage: \"trash\")\n      }\n    }\n  }<\/pre>\n\n\n\n<p>You might notice that we used the <code>role<\/code> parameter on the <code>Button<\/code> to indicate it is <code>.destructive<\/code> &#8211; this instructs SwiftUI to use a red background colour for this button. We still have to implement deleting the item ourselves, though. And since the <code>action<\/code> closure of the <code>Button<\/code> is inside the scope of the current row, it is now much easier directly access the current list <code>item<\/code> &#8211; another advantage of this API design over the previous design for <code>onDelete<\/code>.<\/p>\n\n\n\n<p><strong>Swipe Actions and <code>onDelete<\/code><\/strong><\/p>\n\n\n\n<p>After reading the previous code snippet, you might be wondering why we didn\u2019t use the <code>onDelete<\/code> view modifier instead of implementing a delete action ourselves. The answer is quite simple: as stated in the documentation, SwiftUI will stop synthesising the delete functionality once you use the <code>swipeActions<\/code> modifier.<\/p>\n\n\n\n<p><strong>Adding more Swipe Actions<\/strong><\/p>\n\n\n\n<p>To add multiple swipe actions to either edge, we can call the <code>swipeActions<\/code> modifier multiple times:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List(viewModel.items) { item in\n  Text(item.title)\n    .fontWeight(item.isRead ? .regular : .bold)\n    .swipeActions(edge: .leading) {\n      Button (action: { viewModel.markItemRead(item) }) {\n        if let isRead = item.isRead, isRead == true {\n          Label(\"Read\", systemImage: \"envelope.badge.fill\")\n        }\n        else {\n          Label(\"Unread\", systemImage: \"envelope.open.fill\")\n        }\n      }\n      .tint(.blue)\n    }\n    .swipeActions(edge: .trailing) {\n      Button(role: .destructive, action: { viewModel.deleteItem(item) } ) {\n        Label(\"Delete\", systemImage: \"trash\")\n      }\n    }\n    .swipeActions(edge: .trailing) {\n      Button (action: { selectedItem = item  } ) {\n        Label(\"Tag\", systemImage: \"tag\")\n      }\n      .tint(Color(UIColor.systemOrange))\n    }\n}<\/pre>\n\n\n\n<p>If this makes you feel uneasy, you can also add multiple buttons to the same <code>swipeActions<\/code> modifier. The following code snippet results in the same UI as the previous one:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">List(viewModel.items) { item in\n  Text(item.title)\n    .fontWeight(item.isRead ? .regular : .bold)\n    .badge(item.badge)\n    .swipeActions(edge: .leading) {\n      Button (action: { viewModel.markItemRead(item) }) {\n        if let isRead = item.isRead, isRead == true {\n          Label(\"Read\", systemImage: \"envelope.badge.fill\")\n        }\n        else {\n          Label(\"Unread\", systemImage: \"envelope.open.fill\")\n        }\n      }\n      .tint(.blue)\n    }\n    .swipeActions(edge: .trailing) {\n      Button(role: .destructive, action: { viewModel.deleteItem(item) } ) {\n        Label(\"Delete\", systemImage: \"trash\")\n      }\n      Button (action: { selectedItem = item  } ) {\n        Label(\"Tag\", systemImage: \"tag\")\n      }\n      .tint(Color(UIColor.systemOrange))\n    }\n}<\/pre>\n\n\n\n<p>If you add multiple swipe actions to the same edge, they will be shown from the outside in. I.e. the first button will always appear closest to the respective edge.<\/p>\n\n\n\n<p><strong>Please note<\/strong> that, although there doesn\u2019t seem to be any limit as to how many swipe actions you can add to either edge of a row, the number of actions that a user can comfortably use depends on their device. For example, a list row on an iPhone 13 in portrait orientation can fit up to five swipe actions, but they completely fill up the entire row, which not only looks strange, but also leads to some issues when trying to tap the right button. Smaller devices, like an iPhone 6 or even and iPhone 5, can fit even fewer swipe actions. Three or four swipe actions seem to be a sensible limit that should work on most devices.<\/p>\n\n\n\n<p><strong>Full Swipe<\/strong><\/p>\n\n\n\n<p>By default, the first action for any given swipe direction can be invoked by using a full swipe. You can deactivate this behaviour by setting the <code>allowsFullSwipe<\/code> parameter to <code>false<\/code>:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">.swipeActions(edge: .trailing, allowsFullSwipe: false) {\n  Button(role: .destructive, action: { viewModel.deleteItem(item) } ) {\n    Label(\"Delete\", systemImage: \"trash\")\n  }\n}<\/pre>\n\n\n\n<p><strong>Styling Your Swipe Actions<\/strong><\/p>\n\n\n\n<p>As mentioned before, setting the <code>role<\/code> of a swipe action&#8217;s <code>Button<\/code> to <code>.destructive<\/code> will automatically tint the button red. If you don&#8217;t specify a <code>role<\/code>, the <code>Button<\/code> will be tinted in light grey. You can specify any other colour by using the <code>tint<\/code> modifier on a swipe action&#8217;s <code>Button<\/code> &#8211; like so:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">swipeActions(edge: .trailing) {\n  Button (action: { selectedItem = item  } ) {\n    Label(\"Tag\", systemImage: \"tag\")\n  }\n  .tint(Color(UIColor.systemOrange))\n}<\/pre>\n\n\n\n<p>Inside the <code>Button<\/code>, you can display both text labels and \/ or icons, using <code>Image<\/code>, <code>Text<\/code>, or <code>Label<\/code>.<\/p>\n\n\n\n<p><strong>Conclusion<\/strong><\/p>\n\n\n\n<p>SwiftUI makes it easy to implement Swipe Actions, a very popular UI pattern that allows users to invoke contextual actions on list items. In comparison to force-touching or long-pressing a list row to invoke a context menu, Swipe Actions offer a more convenient, fluent, way to interact with your app\u2019s UI.<\/p>\n\n\n\n<p>Thanks for reading, and hope to see you again for the final part of the series when we talk about nested lists (a.k.a trees)!<\/p>\n","protected":false},"excerpt":{"rendered":"<p>In this part of The Ultimate Guide to List Views, we wi [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[],"_links":{"self":[{"href":"https:\/\/92it.top\/index.php?rest_route=\/wp\/v2\/posts\/6091"}],"collection":[{"href":"https:\/\/92it.top\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/92it.top\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/92it.top\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/92it.top\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=6091"}],"version-history":[{"count":2,"href":"https:\/\/92it.top\/index.php?rest_route=\/wp\/v2\/posts\/6091\/revisions"}],"predecessor-version":[{"id":6093,"href":"https:\/\/92it.top\/index.php?rest_route=\/wp\/v2\/posts\/6091\/revisions\/6093"}],"wp:attachment":[{"href":"https:\/\/92it.top\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=6091"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/92it.top\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=6091"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/92it.top\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=6091"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}