In the code bellow I am trying to achieve the moving (dragging) from a list sections to another list section.
I have an enum which contains 5 elements and a state var (activeElements) that contains an array of those enum.
In a view there is a list with 2 sections:
first section contains elements that are selected from the section 2 (let's call them active elements)
the second section contains elements from the enum which are not contained in selected array
In section 1 I should be able to move elements around and also to drag an element to section2 at a desired position.
In section 2 I should be able to drag elements to section 1 and I don't care about the moving elements inside this section.
The section1 works perfect. My problem is that I cannot drag from section2 to section1. I can only drag from section 2 to section 2 (the plus green dot appears only there).
If I add onMove to the section 2 I lose the dragging from section 1 to section 2. The sections will move elements only inside them.
Do you have any suggestions on how to achieve moving from a section to another? Or maybe how I can move elements between 2 foreach in the same section.
Here is the code:
enum RandomElements: Int, CaseIterable {
case element1 = 1
case element2 = 2
case element3 = 3
case element4 = 4
case element5 = 5
}
extension RandomElements {
var string: String {
switch self {
case .element1:
return "element1"
case .element2:
return "element2"
case .element3:
return "element3"
case .element4:
return "element4"
case .element5:
return "element5"
}
}
}
struct TwoSections: View {
#State var activeElements: [RandomElements] = []
#State var listMode: EditMode = .inactive
var body: some View {
List {
Section(header: Text("Active elements")) {
ForEach(activeElements, id: \.self) { elem in
HStack {
Text(elem.string)
Spacer()
Image(systemName: "minus")
.onTapGesture { activeElements.remove(at: activeElements.firstIndex(of: elem)!) }
}
.onDrag { NSItemProvider(object: String(elem.rawValue) as NSString ) }
}
.onInsert(of: [.plainText], perform: dropToSection1)
.onMove { (indexSet, index) in
activeElements.move(fromOffsets: indexSet, toOffset: index)
}
}
Section(header: Text("Available elements")) {
ForEach(RandomElements.allCases, id: \.self) { elem in
if !activeElements.contains(elem) {
HStack {
Text(elem.string)
Spacer()
Image(systemName: "plus")
.onTapGesture { activeElements.append(elem) }
}
.onDrag { NSItemProvider(object: String(elem.rawValue) as NSString ) }
}
}
.onInsert(of: [.plainText], perform: dropToSection2)
// .onMove { (indexSet, index) in
// }
}
}
.toolbar {
EditButton()
}
.environment(\.editMode, .constant(.active))
}
private func dropToSection1(at index: Int, _ items: [NSItemProvider]) {
for item in items {
_ = item.loadObject(ofClass: String.self) { droppedString, _ in
if let statusType = Int(droppedString ?? "") {
activeElements.remove(at: activeElements.firstIndex(of: RandomElements(rawValue: statusType)!)!)
}
}
}
}
private func dropToSection2(at index: Int, _ items: [NSItemProvider]) {
for item in items {
_ = item.loadObject(ofClass: String.self) { droppedString, _ in
if let statusType = Int(droppedString ?? "") {
print("append \(RandomElements(rawValue: statusType)!)")
activeElements.insert(RandomElements(rawValue: statusType)!, at: index)
}
}
}
}
}
Related
I have a container view that contains multiple child views. These child views have different transitions that should be applied when the container view is inserted or removed.
Currently, when I add or remove this container view, the only transition that works is the one applied directly to the container view.
I have tried applying the transitions to each child view, but it doesn't work as expected. Here is a simplified version of my code:
struct Container: View, Identifiable {
let id = UUID()
var body: some View {
HStack {
Text("First")
.transition(.move(edge: .leading)) // this transition is ignored
Text("Second")
.transition(.move(edge: .trailing)) // this transition is ignored
}
.transition(.opacity) // this transition is applied
}
}
struct Example: View {
#State var views: [AnyView] = []
func pushView(_ view: some View) {
withAnimation(.easeInOut(duration: 1)) {
views.append(AnyView(view))
}
}
func popView() {
guard views.count > 0 else { return }
withAnimation(.easeInOut(duration: 1)) {
_ = views.removeLast()
}
}
var body: some View {
VStack(spacing: 30) {
Button("Add") {
pushView(Container()) // any type of view can be pushed
}
VStack {
ForEach(views.indices, id: \.self) { index in
views[index]
}
}
Button("Remove") {
popView()
}
}
}
}
And here's a GIF that shows the default incorrect behaviour:
If I remove the container's HStack and make the children tuple views, then the individual transitions will work, but I will essentially lose the container — which in this scenario was keeping the children aligned next to each other.
e.g
So this isn't a useful solution.
Note: I want to emphasise that the removal transitions are equally important to me
The .transition is applied to the View that appears (or disappears), and as you've found any .transition on a subview is ignored.
You can work around this by adding your Container without animation, and then animating in each of the Text.
struct Pair: Identifiable {
let id = UUID()
let first = "first"
let second = "second"
}
struct Container: View {
#State private var showFirst = false
#State private var showSecond = false
let pair: Pair
var body: some View {
HStack {
if showFirst {
Text(pair.first)
.transition(.move(edge: .leading))
}
if showSecond {
Text(pair.second)
.transition(.move(edge: .trailing))
}
}
.onAppear {
withAnimation {
showFirst = true
showSecond = true
}
}
}
}
struct ContentView: View {
#State var pairs: [Pair] = []
var animation: Animation = .easeInOut(duration: 1)
var body: some View {
VStack(spacing: 30) {
Button("Add") {
pairs.append(Pair())
}
VStack {
ForEach(pairs) { pair in
Container(pair: pair)
}
}
Button("Remove") {
if pairs.isEmpty { return }
withAnimation(animation) {
_ = pairs.removeLast()
}
}
}
}
}
Also note, your ForEach should be over an array of objects rather than Views (not that it makes a difference in this case).
Update
You can reverse the process by using a Binding to a Bool that contains the show state for each View. In this case I've created a struct PairState that holds a Set of all the views currently shown:
struct Container: View {
let pair: Pair
#Binding var show: Bool
var body: some View {
HStack {
if show {
Text(pair.first)
.transition(.move(edge: .leading))
Text(pair.second)
.transition(.move(edge: .trailing))
}
}
.onAppear {
withAnimation {
show = true
}
}
}
}
struct PairState {
var shownIds: Set<Pair.ID> = []
subscript(pairID: Pair.ID) -> Bool {
get {
shownIds.contains(pairID)
}
set {
shownIds.insert(pairID)
}
}
mutating func remove(_ pair: Pair) {
shownIds.remove(pair.id)
}
}
struct ContentView: View {
#State var pairs: [Pair] = []
#State var pairState = PairState()
var body: some View {
VStack(spacing: 30) {
Button("Add") {
pairs.append(Pair())
}
VStack {
ForEach(pairs) { pair in
Container(pair: pair, show: $pairState[pair.id])
}
}
Button("Remove") {
guard let pair = pairs.last else { return }
Task {
withAnimation {
pairState.remove(pair)
}
try? await Task.sleep(for: .seconds(0.5)) // 😢
_ = pairs.removeLast()
}
}
}
}
}
This has a delay in there to wait for the animation to complete before removing from the array. I'm not happy with that, but it works in this example.
I have a view body with logic such as this:
var body: some View {
VStack {
if someCondition {
SomeView()
}
if anotherCondition {
AnotherView()
AnotherView()
}
if thirdCondition {
SomeView()
AnotherView()
}
}
}
This works fine to conditionally show elements vertically stacked. However, if none of the conditions are satisfied, the VStack is empty and my UI looks broken. I would like to show a placeholder instead. My current solution is to add a manual check at the end on !someCondition && !anotherCondition && !thirdCondition:
var body: some View {
VStack {
if someCondition {
SomeView()
}
if anotherCondition {
AnotherView()
AnotherView()
}
if thirdCondition {
SomeView()
AnotherView()
}
if !someCondition && !anotherCondition && !thirdCondition { // 👈
Text("Please select an element.")
}
}
}
However, this is difficult to keep the condition in sync with the content above. I was hoping there was some sort of view modifier I could use such as:
var body: some View {
VStack {
if someCondition {
SomeView()
}
if anotherCondition {
AnotherView()
AnotherView()
}
if thirdCondition {
SomeView()
AnotherView()
}
}.emptyState { // 👈
Text("Please select an element.")
}
}
The closest thing I could find is this tutorial, but that requires passing in the condition as well.
Is there a way to build a view modifier like this emptyState which doesn't require duplicating the condition logic?
I was thinking I could use a ZStack for this:
var body: some View {
ZStack { // 👈
// empty state text
Text("Please select an element.")
VStack {
if someCondition {
SomeView()
}
if anotherCondition {
AnotherView()
AnotherView()
}
if thirdCondition {
SomeView()
AnotherView()
}
}
}
}
... but then I run into a different issue where if I'm showing real content (e.g. SomeView()) but it's not large enough, I could see both SomeView() and the empty state text.
Here's one implementation using GeometryReader & it's named emptyState:
extension View {
func emptyState<Content: View>(#ViewBuilder content: () -> Content) -> some View {
return self.modifier(EmptyStateModifier(placeHolder: content()))
}
}
struct EmptyStateModifier<PlaceHolder: View>: ViewModifier {
#State var isEmpty = false
let placeHolder: PlaceHolder
func body(content: Content) -> some View {
ZStack {
if isEmpty {//Thanks to #Asperi
placeHolder
}
content
.background(
GeometryReader { reader in
Color.clear
.onChange(of: reader.frame(in: .global).size == .zero) { newValue in
isEmpty = reader.frame(in: .global).size == .zero
}
}
)
}
}
}
If it is a long chaining condition, you can handle it with switch{}, then use the benefit of default to display the placeholder when 0 condition is met(stack is empty or no selection)
#State var selected = ""
var body: some View {
VStack {
switch selected {
case "a":
SomeView()
case "b":
AnotherView()
case "c":
ThirdView()
//this default will show up
//when there is no selection
//and when the stack is empty meaning that all the above
//conditions did not meet
default:
Text("Please select an element")
}
}
}
There is no straightforward, officially supported way of telling what the return value of a view builder contains.
It is more sensible to handle this at the model layer than in your view. Each of your conditions are part of your model. These should be wrapped up into a single value type, and you can use the presence or absence of that (or an internal calculated value of that, depending on your requirements) to inform what to put in the stack. For example:
struct Model {
let one: Bool
let two: Bool
let three: Bool
}
struct MyView: View {
let model: Model?
var body: some View {
VStack {
switch model {
case .some(let model):
if model.one {
Text("One")
}
if model.two {
Text("Two")
}
if model.three {
Text("Three")
}
case .none:
Text("Empty")
}
}
}
}
(I'm assuming here that there is no valid Model which doesn't contain any of the values, that would be when it is set to nil)
I have realized that some caching mechanism is present if the List's row is a View.
Let's start with a simple list:
struct ContentView: View {
#State var model = [String]()
var body: some View {
VStack {
Button("Add") {
model.append(UUID().uuidString)
}
List {
ForEach(0 ..< model.count, id:\.self) { idx in
let item = model[idx]
Print("\(idx)")
makeRow(item: item)
}
}
}
}
func makeRow(item: String) -> some View {
HStack {
Print("Row \(item)")
Text("\(item)")
}
}
}
extension View {
func Print(_ vars: Any...) -> some View {
for v in vars { print(v) }
return EmptyView()
}
}
Adding a new single row, this appears on the console:
4
Row 44899379-B7FA-4667-ADB9-86A59EA69EF0
0
Row A219638D-9C6E-42C8-9055-C5569B20D9B8
1
Row B6187186-408F-46B2-A121-840C399CB12F
2
Row 856F873F-8639-4CA4-A832-19EF92BFA7A4
3
Row 0C080275-12D9-451C-8C7A-C8B97C4DE658
Then each row is recreated.
Let's replace each row with a View:
func makeRow(item: String) -> some View {
RowView(item: item)
}
struct RowView: View {
var item: String
var body: some View {
HStack {
Print("Row \(item)")
Text("\(item)")
}
}
}
Now the console shows:
4
Row E55A0F48-5848-4CFE-8612-AB8CC799F532
0
1
2
3
Only the RowView for the just added row is created.
Apparently all the other RowViews all cached. I think this is good in most cases.
I'm working on a complex application and, in some use cases, I need to redraw all the rows.
How can I force to recreate each RowView?
I have a Combine function that I use to search through a list of items and return matches. It keeps track of not only what items to show the user that match the search term, but also what items have been marked as "chosen" by the user.
The function works great, including animations, until I add either .debounce(for: .seconds(0.2), scheduler: RunLoop.main) or .receive(on: RunLoop.main) in the Combine publisher chain. At that point, the rendering of the results in the View get inexplicably strange -- item titles start showing up as header views, items are repeated, etc.
You can see the result in the accompanying GIF.
The GIF version is using .receive(on: RunLoop.main). Note I don't even use the search term here, although it also leads to funny results. It also may be worth noting that everything works correctly with the problem lines if withAnimation { } is removed.
I'd like to be able to use debounce as the list may eventually be pretty large and I don't want to filter the whole list on every keystroke.
How can I get the table view to render correctly under these circumstances?
Example code (see inline comments for the pain points and explanation of the code. It should run well as written, but if either of the two relevant lines is uncommented) :
import SwiftUI
import Combine
import UIKit
class Completer : ObservableObject {
#Published var items : [Item] = [] {
didSet {
setupPipeline()
}
}
#Published var filteredItems : [Item] = []
#Published var chosenItems: Set<Item> = []
#Published var searchTerm = ""
private var filterCancellable : AnyCancellable?
private func setupPipeline() {
filterCancellable =
Publishers.CombineLatest($searchTerm,$chosenItems) //listen for changes of both the search term and chosen items
.print()
// ** Either of the following lines, if uncommented will cause chaotic rendering of the table **
//.receive(on: RunLoop.main) //<----- HERE --------------------
//.debounce(for: .seconds(0.2), scheduler: RunLoop.main) //<----- HERE --------------------
.map { (term,chosen) -> (filtered: [Item],chosen: Set<Item>) in
if term.isEmpty { //if the term is empty, return everything
return (filtered: self.items, chosen: chosen)
} else { //if the term is not empty, return only items that contain the search term
return (filtered: self.items.filter { $0.name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains($0) }, chosen: chosen) //don't include any items in the chosen items list
}
.sink { [weak self] (filtered, chosen) in
self?.filteredItems = filtered
}
}
func toggleItemChosen(item: Item) {
withAnimation {
if chosenItems.contains(item) {
chosenItems.remove(item)
} else {
searchTerm = ""
chosenItems.insert(item)
}
}
}
}
struct ContentView: View {
#StateObject var completer = Completer()
var body: some View {
Form {
Section {
TextField("Term", text: $completer.searchTerm)
}
Section {
ForEach(completer.filteredItems) { item in
Button(action: {
completer.toggleItemChosen(item: item)
}) {
Text(item.name)
}.foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)
}
}
if completer.chosenItems.count != 0 {
Section(header: HStack {
Text("Chosen items")
Spacer()
Button(action: {
completer.chosenItems = []
}) {
Text("Clear")
}
}) {
ForEach(Array(completer.chosenItems)) { item in
Button(action: {
completer.toggleItemChosen(item: item)
}) {
Text(item.name)
}
}
}
}
}.onAppear {
completer.items = ["Chris", "Greg", "Ross", "Damian", "George", "Darrell", "Michael"]
.map { Item(name: $0) }
}
}
}
struct Item : Identifiable, Hashable {
var id = UUID()
var name : String
}
The problem in handling async processing... In your default case all operations are performed synchronously within one(!) animation block, so all works fine. But in second scenario (by introducing any scheduler in publishers chain) some operations are performed synchronously (like removing) that initiates animation, but operation from publisher comes asynchronously at the moment when animation is already in progress, and changing model breaks that running animation giving unpredictable result.
The possible approach to solve this is to separate initiating and resulting operations by different blocks and make publishers chan really async but processing in background and retrieving results in main queue.
Here is modified publishers chain. Tested with Xcode 12.4 / iOS 14.4
Note: also you can investigate possibility of wrapping all again in one animation block, but already in synk after retrieving results - this will require changing logic so it just for consideration
private func setupPipeline() {
filterCancellable =
Publishers.CombineLatest($searchTerm,$chosenItems)
.debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) // debounce input
.subscribe(on: DispatchQueue.global(qos: .background)) // prepare for processing in background
.print()
.map { (term,chosen) -> (filtered: [DItem],chosen: Set<DItem>) in
if term.isEmpty { //if the term is empty, return everything
return (filtered: self.items, chosen: chosen)
} else { //if the term is not empty, return only items that contain the search term
return (filtered: self.items.filter { $0.name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains($0) }, chosen: chosen) //don't include any items in the chosen items list
}
.receive(on: DispatchQueue.main) // << receive processed items on main queue
.sink { [weak self] (filtered, chosen) in
withAnimation {
self?.filteredItems = filtered // animating this as well
}
}
}
#Asperi's suggestion got me on the right track thinking about how many withAnimation { } events would get called. In my original question, filteredItems and chosenItems would be changed in different iterations of the RunLoop when receive(on:) or debounce was used, which seemed to be the root cause of the unpredictable layout behavior.
By changing the debounce time to a longer value, this would prevent the issue, because one animation would be done after the other was finished, but was a problematic solution because it relied on the animation times (and potentially magic numbers if explicit animation times weren't sent).
I've engineered a somewhat tacky solution that uses a PassThroughSubject for chosenItems instead of assigning to the #Published property directly. By doing this, I can move all assignment of the #Published values into the sink, resulting in just one animation block happening.
I'm not thrilled with the solution, as it feels like an unnecessary hack, but it does seem to solve the issue:
class Completer : ObservableObject {
#Published var items : [Item] = [] {
didSet {
setupPipeline()
}
}
#Published private(set) var filteredItems : [Item] = []
#Published private(set) var chosenItems: Set<Item> = []
#Published var searchTerm = ""
private var chosenPassthrough : PassthroughSubject<Set<Item>,Never> = .init()
private var filterCancellable : AnyCancellable?
private func setupPipeline() {
filterCancellable =
Publishers.CombineLatest($searchTerm,chosenPassthrough)
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.map { (term,chosen) -> (filtered: [Item],chosen: Set<Item>) in
if term.isEmpty {
return (filtered: self.items, chosen: chosen)
} else {
return (filtered: self.items.filter { $0.name.localizedStandardContains(term) }, chosen: chosen)
}
}
.map { (filtered,chosen) in
(filtered: filtered.filter { !chosen.contains($0) }, chosen: chosen)
}
.sink { [weak self] (filtered, chosen) in
withAnimation {
self?.filteredItems = filtered
self?.chosenItems = chosen
}
}
chosenPassthrough.send([])
}
func toggleItemChosen(item: Item) {
if chosenItems.contains(item) {
var copy = chosenItems
copy.remove(item)
chosenPassthrough.send(copy)
} else {
var copy = chosenItems
copy.insert(item)
chosenPassthrough.send(copy)
}
searchTerm = ""
}
func clearChosen() {
chosenPassthrough.send([])
}
}
struct ContentView: View {
#StateObject var completer = Completer()
var body: some View {
Form {
Section {
TextField("Term", text: $completer.searchTerm)
}
Section {
ForEach(completer.filteredItems) { item in
Button(action: {
completer.toggleItemChosen(item: item)
}) {
Text(item.name)
}.foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)
}
}
if completer.chosenItems.count != 0 {
Section(header: HStack {
Text("Chosen items")
Spacer()
Button(action: {
completer.clearChosen()
}) {
Text("Clear")
}
}) {
ForEach(Array(completer.chosenItems)) { item in
Button(action: {
completer.toggleItemChosen(item: item)
}) {
Text(item.name)
}
}
}
}
}.onAppear {
completer.items = ["Chris", "Greg", "Ross", "Damian", "George", "Darrell", "Michael"]
.map { Item(name: $0) }
}
}
}
struct Item : Identifiable, Hashable, Equatable {
var id = UUID()
var name : String
}
I'm using Xcode 12 beta and trying to create a view where items from a left list can be dragged onto a right list and dropped there.
This crashes in the following situations:
The list is empty.
The list is not empty, but the item is dragged behind the last list element, after dragging it onto other list elements first. The crash already appears while the item is dragged, not when it is dropped (i.e., the .onInsert is not called yet).
The crash message tells:
SwiftUI`generic specialization <SwiftUI._ViewList_ID.Views> of (extension in Swift):Swift.RandomAccessCollection< where A.Index: Swift.Strideable, A.Indices == Swift.Range<A.Index>, A.Index.Stride == Swift.Int>.index(after: A.Index) -> A.Index:
Are there any ideas why this happens and how it can be avoided?
The left list code:
struct AvailableBuildingBricksView: View {
#StateObject var buildingBricksProvider: BuildingBricksProvider = BuildingBricksProvider()
var body: some View {
List {
ForEach(buildingBricksProvider.availableBuildingBricks) { buildingBrickItem in
Text(buildingBrickItem.title)
.onDrag {
self.provider(buildingBrickItem: buildingBrickItem)
}
}
}
}
private func provider(buildingBrickItem: BuildingBrickItem) -> NSItemProvider {
let image = UIImage(systemName: buildingBrickItem.systemImageName) ?? UIImage()
let provider = NSItemProvider(object: image)
provider.suggestedName = buildingBrickItem.title
return provider
}
}
final class BuildingBricksProvider: ObservableObject {
#Published var availableBuildingBricks: [BuildingBrickItem] = []
init() {
self.availableBuildingBricks = [
TopBrick.personalData,
TopBrick.education,
TopBrick.work,
TopBrick.overviews
].map({ return BuildingBrickItem(title: $0.title,
systemImageName: "stop") })
}
}
struct BuildingBrickItem: Identifiable {
var id: UUID = UUID()
var title: String
var systemImageName: String
}
The right list code:
struct DocumentStructureView: View {
#StateObject var documentStructureProvider: DocumentStructureProvider = DocumentStructureProvider()
var body: some View {
List {
ForEach(documentStructureProvider.documentSections) { section in
Text(section.title)
}
.onInsert(of: ["public.image"]) {
self.insertSection(position: $0,
itemProviders: $1,
top: true)
}
}
}
func insertSection(position: Int, itemProviders: [NSItemProvider], top: Bool) {
for item in itemProviders.reversed() {
item.loadObject(ofClass: UIImage.self) { image, _ in
if let _ = image as? UIImage {
DispatchQueue.main.async {
let section = DocumentSectionItem(title: item.suggestedName ?? "Unknown")
self.documentStructureProvider.insert(section: section, at: position)
}
}
}
}
}
}
final class DocumentStructureProvider: ObservableObject {
#Published var documentSections: [DocumentSectionItem] = []
init() {
documentSections = [
DocumentSectionItem(title: "Dummy")
]
}
func insert(section: DocumentSectionItem, at position: Int) {
if documentSections.count == 0 {
documentSections.append(section)
return
}
documentSections.insert(section, at: position)
}
}
struct DocumentSectionItem: Identifiable {
var id: UUID = UUID()
var title: String
}
Well, I succeeded to make the problem reproducable, code below.
Steps to reproduce:
Drag "A" on "1" as first item on the right.
Drag another "A" on "1", hold it dragged, draw it slowly down after "5" -> crash.
The drop function is not called before the crash.
struct ContentView: View {
var body: some View {
HStack {
LeftList()
Divider()
RightList()
}
}
}
import SwiftUI
import UniformTypeIdentifiers
struct LeftList: View {
var list: [String] = ["A", "B", "C", "D", "E"]
var body: some View {
List(list) { item in
Text(item)
.onDrag {
let stringItemProvider = NSItemProvider(object: item as NSString)
return stringItemProvider
}
}
}
}
import SwiftUI
import UniformTypeIdentifiers
struct RightList: View {
#State var list: [String] = ["1", "2", "3", "4", "5"]
var body: some View {
List {
ForEach(list) { item in
Text(item)
}
.onInsert(
of: [UTType.text],
perform: drop)
}
}
private func drop(at index: Int, _ items: [NSItemProvider]) {
debugPrint(index)
for item in items {
_ = item.loadObject(ofClass: NSString.self) { text, _ in
debugPrint(text)
DispatchQueue.main.async {
debugPrint("dispatch")
text.map { self.list.insert($0 as! String, at: index) }
}
}
}
}
}