As far as I know, you can't use if let on variables that aren't optional...
func run() {
let title = "Hello, world!"
if let title = title { /// Initializer for conditional binding must have Optional type, not 'String'
print(title)
}
}
... but for some reason, when it's inside a closure, it works:
func run() {
let title = "Hello, world!"
let closure = {
if let title = title { /// No error!
print(title)
}
}
closure()
}
This was on Xcode 14.0 beta (14A5228q). Is this a bug, or a feature? When I tested on Xcode 13.3 (13E113), the error appeared again.
On a side note, the following SwiftUI code compiles fine on both Xcode 13 and Xcode 14:
struct ContentView: View {
let title = "Hello, world!"
var body: some View {
VStack {
if let title = title { /// Works fine!
Text(title)
}
}
}
}
And it's not just VStack — anything marked #ViewBuilder seems to work.
struct ContentView: View {
let title = "Hello, world!"
var body: some View {
ContainerView {
if let title = title { /// works fine!
Text(title)
}
}
}
}
struct ContainerView<Content: View>: View {
#ViewBuilder var content: Content
var body: some View {
content
}
}
Is this a documented feature, or a bug?
Related
Why, in the following app when clicking through to 'Nice Restaurant' and trying to add a contributor, does the app crash with the error:
Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range?
The error, in the Xcode debugger, has no obviously useful stack trace and points straight at the '#main' line.
There are no explicit array indices used in the code nor any uses of members like .first.
I'm using Xcode Version 13.4.1 (13F100)
I'm using simulator: iPhone 13 iOS 15.5 (19F70)
import SwiftUI
struct CheckContribution: Identifiable {
let id: UUID = UUID()
var name: String = ""
}
struct Check: Identifiable {
var id: UUID = UUID()
var title: String
var contributions: [CheckContribution]
}
let exampleCheck = {
return Check(
title: "Nice Restaurant",
contributions: [
CheckContribution(name: "Bob"),
CheckContribution(name: "Alice"),
]
)
}()
struct CheckView: View {
#Binding var check: Check
#State private var selectedContributor: CheckContribution.ID? = nil
func addContributor() {
let newContribution = CheckContribution()
check.contributions.append(newContribution)
selectedContributor = newContribution.id
}
var body: some View {
List {
ForEach($check.contributions) { $contribution in
TextField("Name", text: $contribution.name)
}
Button(action: addContributor) {
Text("Add Contributor")
}
}
}
}
#main
struct CheckSplitterApp: App {
#State private var checks: [Check] = [exampleCheck]
var body: some Scene {
WindowGroup {
NavigationView {
List {
ForEach($checks) { $check in
NavigationLink(destination: {
CheckView(check: $check)
}) {
Text(check.title).font(.headline)
}
}
}
}
}
}
}
I've noticed that:
If I unroll the ForEach($checks) the crash doesn't occur (but I need to keep the ForEach so I can list all the checks)
If I don't take a binding to the CheckContribution (ForEach($check.contributions) { $contribution in then the crash doesn't occur (but I need the binding so subviews can modify the CheckContribution
If I don't set the selectedContributor then the crash doesn't occur (but I need the selectedContributor in the real app for navigation purposes)
The cleanest way I could find that actually works is to further separate the nested ForEach into a subview and bind the contributors array to it.
struct CheckView: View {
#Binding var check: Check
#State private var selectedContributor: CheckContribution.ID? = nil
func addContributor() {
let newContribution = CheckContribution()
check.contributions.append(newContribution)
selectedContributor = newContribution.id
}
var body: some View {
List {
ContributionsView(contributions: $check.contributions)
Button(action: addContributor) {
Text("Add Contributor")
}
// Test that changing other properties still works.
Button("Change title", action: changeTitle)
}
.navigationTitle(check.title)
}
func changeTitle() {
check.title = "\(Int.random(in: 1...100))"
}
}
struct ContributionsView: View {
#Binding var contributions: [CheckContribution]
var body: some View {
ForEach($contributions) { $contribution in
TextField("Name", text: $contribution.name)
}
}
}
I'm still not sure about the internals of SwiftUI, and why it works this way. I hope it helps. And maybe another more experienced user can provide a clear explanation to this.
If you really want the Button to be in the List, then you could try this approach using a separate view, works well for me:
struct CheckView: View {
#Binding var check: Check
#State private var selectedContributor: CheckContribution.ID? = nil
var body: some View {
List {
ForEach($check.contributions) { $contribution in
TextField("Name", text: $contribution.name)
}
AddButtonView(check: $check) // <-- here
}
}
}
struct AddButtonView: View {
#Binding var check: Check
func addContributor() {
let newContribution = CheckContribution(name: "new contribution")
check.contributions.append(newContribution)
}
var body: some View {
Button(action: addContributor) {
Text("Add Contributor")
}
}
}
I had the same error but with tabview. Moreover, the fall was only on iOS 15, but on iOS 16 it worked perfectly and there were no falls.
I tried both through indexes, and through checking for finding the index inside the range, but nothing helped.
The solution was found in the process of debugging: I noticed that it was falling even before the predstavlenie appeared (it worked Appear).
I did a simple check to see if the data array is empty
if !dataArray.isEmpty {
TabView(selection: $selection) {
ForEach(dataArray, id: \.self) { item in
...
}
}
}
And it worked - there were no more crashes on iOS 15. Apparently there was some problem with the processing of empty arrays before iOS 16.
Here is a simple list view of "Topic" struct items. The goal is to present an editor view when a row of the list is tapped. In this code, tapping a row is expected to cause the selected topic to be stored as "tappedTopic" in an #State var and sets a Boolean #State var that causes the EditorV to be presented.
When the code as shown is run and a line is tapped, its topic name prints properly in the Print statement in the Button action, but then the app crashes because self.tappedTopic! finds tappedTopic to be nil in the EditTopicV(...) line.
If the line "tlVM.objectWillChange.send()" is uncommented, the code runs fine. Why is this needed?
And a second puzzle: in the case where the code runs fine, with the objectWillChange.send() uncommented, a print statement in the EditTopicV init() shows that it runs twice. Why?
Any help would be greatly appreciated. I am using Xcode 13.2.1 and my deployment target is set to iOS 15.1.
Topic.swift:
struct Topic: Identifiable {
var name: String = "Default"
var iconName: String = "circle"
var id: String { name }
}
TopicListV.swift:
struct TopicListV: View {
#ObservedObject var tlVM: TopicListVM
#State var tappedTopic: Topic? = nil
#State var doEditTappedTopic = false
var body: some View {
VStack(alignment: .leading) {
List {
ForEach(tlVM.topics) { topic in
Button(action: {
tappedTopic = topic
// why is the following line needed?
tlVM.objectWillChange.send()
doEditTappedTopic = true
print("Tapped topic = \(tappedTopic!.name)")
}) {
Label(topic.name, systemImage: topic.iconName)
.padding(10)
}
}
}
Spacer()
}
.sheet(isPresented: $doEditTappedTopic) {
EditTopicV(tlVM: tlVM, originalTopic: self.tappedTopic!)
}
}
}
EditTopicV.swift (Editor View):
struct EditTopicV: View {
#ObservedObject var tlVM: TopicListVM
#Environment(\.presentationMode) var presentationMode
let originalTopic: Topic
#State private var editTopic: Topic
#State private var ic = "circle"
let iconList = ["circle", "leaf", "photo"]
init(tlVM: TopicListVM, originalTopic: Topic) {
print("DBG: EditTopicV: originalTopic = \(originalTopic)")
self.tlVM = tlVM
self.originalTopic = originalTopic
self._editTopic = .init(initialValue: originalTopic)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
Spacer()
Button("Save") {
editTopic.iconName = editTopic.iconName.lowercased()
tlVM.change(topic: originalTopic, to: editTopic)
presentationMode.wrappedValue.dismiss()
}
}
HStack {
Text("Name:")
TextField("name", text: $editTopic.name)
Spacer()
}
Picker("Color Theme", selection: $editTopic.iconName) {
ForEach(iconList, id: \.self) { icon in
Text(icon).tag(icon)
}
}
.pickerStyle(.segmented)
Spacer()
}
.padding()
}
}
TopicListVM.swift (Observable Object View Model):
class TopicListVM: ObservableObject {
#Published var topics = [Topic]()
func append(topic: Topic) {
topics.append(topic)
}
func change(topic: Topic, to newTopic: Topic) {
if let index = topics.firstIndex(where: { $0.name == topic.name }) {
topics[index] = newTopic
}
}
static func ex1() -> TopicListVM {
let tvm = TopicListVM()
tvm.append(topic: Topic(name: "leaves", iconName: "leaf"))
tvm.append(topic: Topic(name: "photos", iconName: "photo"))
tvm.append(topic: Topic(name: "shapes", iconName: "circle"))
return tvm
}
}
Here's what the list looks like:
Using sheet(isPresented:) has the tendency to cause issues like this because SwiftUI calculates the destination view in a sequence that doesn't always seem to make sense. In your case, using objectWillSend on the view model, even though it shouldn't have any effect, seems to delay the calculation of your force-unwrapped variable and avoids the crash.
To solve this, use the sheet(item:) form:
.sheet(item: $tappedTopic) { item in
EditTopicV(tlVM: tlVM, originalTopic: item)
}
Then, your item gets passed in the closure safely and there's no reason for a force unwrap.
You can also capture tappedTopic for a similar result, but you still have to force unwrap it, which is generally something we want to avoid:
.sheet(isPresented: $doEditTappedTopic) { [tappedTopic] in
EditTopicV(tlVM: tlVM, originalTopic: tappedTopic!)
}
I am using .sheet view in SwiftUI and I am observing a strange behavior in the execution of the code.
I am having a view SignInView2:
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State var invitationUrl = URL(string: "www")
#State private var showingSheet = false
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)") // Here I see the new value assigned from createLink()
self.showingSheet = true
}) {
Text("Share")
}
.sheet(isPresented: $showingSheet) {
let invitationLink = invitationUrl?.absoluteString // Paasing the old value (www)
ActivityView(activityItems: [NSURL(string: invitationLink!)] as [Any], applicationActivities: nil)
}
}
.onAppear() {
createLink()
}
}
}
which calls create a link method when it appears:
extension SignInView2 {
func createLink() {
guard let uid = Auth.auth().currentUser?.uid else {
print("tuk0")
return }
let link = URL(string: "https://www.example.com/?invitedby=\(uid)")
print("tuk1:\(String(describing: link))")
let referralLink = DynamicLinkComponents(link: link!, domainURIPrefix: "https://makeitso.page.link")
print("tuk2:\(String(describing: referralLink))")
referralLink?.iOSParameters = DynamicLinkIOSParameters(bundleID: "com.IVANDOS.ToDoFirebase")
referralLink?.iOSParameters?.minimumAppVersion = "1.0"
referralLink?.iOSParameters?.appStoreID = "13129650"
referralLink?.shorten { (shortURL, warnings, error) in
if let error = error {
print(error.localizedDescription)
return
}
print("tuk4: \(shortURL)")
self.invitationUrl = shortURL!
}
}
}
That method assigns a value to the invitationUrl variable, which is passed to the sheet. Unfortunatelly, when the sheet appears, I don't see the newly assigned variable but I see only "www". Can you explain me how to pass the new value generated from createLink()?
This is known behaviour of sheet since SwiftUI 2.0. Content is created in time of sheet created not in time of showing. So the solution can be either to use .sheet(item:... modifier or passing binding in sheet content view (which is kind of reference to state storage and don't need to be updated).
Here is a demo of possible approach. Prepared with Xcode 12.4.
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State private var invitationUrl: URL? // by default is absent
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)")
self.invitationUrl = createLink() // assignment activates sheet
}) {
Text("Share")
}
.sheet(item: $invitationUrl) {
ActivityView(activityItems: [$0] as [Any], applicationActivities: nil)
}
}
}
}
// Needed to be used as sheet item
extension URL: Identifiable {
public var id: String { self.absoluteString }
}
I am using .sheet view in SwiftUI and I am observing a strange behavior in the execution of the code.
I am having a view SignInView2:
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State var invitationUrl = URL(string: "www")
#State private var showingSheet = false
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)") // Here I see the new value assigned from createLink()
self.showingSheet = true
}) {
Text("Share")
}
.sheet(isPresented: $showingSheet) {
let invitationLink = invitationUrl?.absoluteString // Paasing the old value (www)
ActivityView(activityItems: [NSURL(string: invitationLink!)] as [Any], applicationActivities: nil)
}
}
.onAppear() {
createLink()
}
}
}
which calls create a link method when it appears:
extension SignInView2 {
func createLink() {
guard let uid = Auth.auth().currentUser?.uid else {
print("tuk0")
return }
let link = URL(string: "https://www.example.com/?invitedby=\(uid)")
print("tuk1:\(String(describing: link))")
let referralLink = DynamicLinkComponents(link: link!, domainURIPrefix: "https://makeitso.page.link")
print("tuk2:\(String(describing: referralLink))")
referralLink?.iOSParameters = DynamicLinkIOSParameters(bundleID: "com.IVANDOS.ToDoFirebase")
referralLink?.iOSParameters?.minimumAppVersion = "1.0"
referralLink?.iOSParameters?.appStoreID = "13129650"
referralLink?.shorten { (shortURL, warnings, error) in
if let error = error {
print(error.localizedDescription)
return
}
print("tuk4: \(shortURL)")
self.invitationUrl = shortURL!
}
}
}
That method assigns a value to the invitationUrl variable, which is passed to the sheet. Unfortunatelly, when the sheet appears, I don't see the newly assigned variable but I see only "www". Can you explain me how to pass the new value generated from createLink()?
This is known behaviour of sheet since SwiftUI 2.0. Content is created in time of sheet created not in time of showing. So the solution can be either to use .sheet(item:... modifier or passing binding in sheet content view (which is kind of reference to state storage and don't need to be updated).
Here is a demo of possible approach. Prepared with Xcode 12.4.
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State private var invitationUrl: URL? // by default is absent
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)")
self.invitationUrl = createLink() // assignment activates sheet
}) {
Text("Share")
}
.sheet(item: $invitationUrl) {
ActivityView(activityItems: [$0] as [Any], applicationActivities: nil)
}
}
}
}
// Needed to be used as sheet item
extension URL: Identifiable {
public var id: String { self.absoluteString }
}
SwiftUI and Combine noob here, I isolated in a playground the problem I am having. Here is the playground.
final class ReactiveContainer<T: Equatable> {
#Published var containedValue: T?
}
class AppContainer {
static let shared = AppContainer()
let text = ReactiveContainer<String>()
}
struct TestSwiftUIView: View {
#State private var viewModel = "test"
var body: some View {
Text("\(viewModel)")
}
init(textContainer: ReactiveContainer<String>) {
textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.assign(to: \.viewModel, on: self)
}
}
AppContainer.shared.text.containedValue = "init"
var testView = TestSwiftUIView(textContainer: AppContainer.shared.text)
print(testView)
print("Executing network request")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
AppContainer.shared.text.containedValue = "Hello world"
print(testView)
}
When I run the playground this is what's happening:
compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
Executing network request
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
So as you can see, two problems there:
The compact map closure is only called once, on subscription but not when the dispatch is ran
The assign operator is never called
I have been trying to solve this these past few hours without any success. Maybe someone with a top knowledge in SwiftUI/Combine could help me, thx !
EDIT
Here is the working solution:
struct ContentView: View {
#State private var viewModel = "test"
let textContainer: ReactiveContainer<String>
var body: some View {
Text(viewModel).onReceive(textContainer.$containedValue) { (newContainedValue) in
self.viewModel = newContainedValue ?? ""
}
}
init(textContainer: ReactiveContainer<String>) {
self.textContainer = textContainer
}
}
I would prefer to use ObservableObject/ObservedObject pattern, right below, but other variants also possible (as provided further)
All tested with Xcode 11.2 / iOS 13.2
final class ReactiveContainer<T: Equatable>: ObservableObject {
#Published var containedValue: T?
}
struct TestSwiftUIView: View {
#ObservedObject var vm: ReactiveContainer<String>
var body: some View {
Text("\(vm.containedValue ?? "<none>")")
}
init(textContainer: ReactiveContainer<String>) {
self._vm = ObservedObject(initialValue: textContainer)
}
}
Alternates:
The following fixes your case (if you don't store subscriber the publisher is canceled immediately)
private var subscriber: AnyCancellable?
init(textContainer: ReactiveContainer<String>) {
subscriber = textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.assign(to: \.viewModel, on: self)
}
Please note, view's state is linked only being in view hierarchy, in Playground like you did it holds only initial value.
Another possible approach, that fits better for SwiftUI hierarchy is
struct TestSwiftUIView: View {
#State private var viewModel: String = "test"
var body: some View {
Text("\(viewModel)")
.onReceive(publisher) { value in
self.viewModel = value
}
}
let publisher: AnyPublisher<String, Never>
init(textContainer: ReactiveContainer<String>) {
publisher = textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.eraseToAnyPublisher()
}
}
I would save a reference to AppContainer.
struct TestSwiftUIView: View {
#State private var viewModel = "test"
///I just added this
var textContainer: AnyCancellable?
var body: some View {
Text("\(viewModel)")
}
init(textContainer: ReactiveContainer<String>) {
self.textContainer = textContainer.$containedValue.compactMap {
print("compact map \(String(describing: $0))")
return $0
}.assign(to: \.viewModel, on: self)
}
}
compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
Executing network request
compact map Optional("Hello")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
We don't use Combine for moving data between Views, SwiftUI already has built-in support for this. The main problem is you are treating the TestSwiftUIView as if it is a class but it is a struct, i.e. a value. It's best to think of the View simply as the data to be displayed. SwiftUI creates these data structs over and over again when data changes. So the solution is simply:
struct ContentView: View {
let text: String
var body: some View { // only called if text is different from last time ContentView was created in a parent View's body.
Text(text)
}
}
The parent body method can call ContentView(text:"Test") over and over again but the ContentView body method is only called by SwiftUI when the let text is different from last time, e.g. ContentView(text:"Test2"). I think this is what you tried to recreate with Combine but it is unnecessary because SwiftUI already does it.