Extend SwiftUI's TextEditor within Form to use remaining space - swift

I'm using a TextEditor inside a Form. A minimal playground example would be
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var text = String()
var body: some View {
Form {
Section("Section") {
Toggle("Toggle", isOn: .constant(true))
LabeledContent("TextEditor") {
TextEditor(text: $text)
}
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
This renders into something like:
As TextEditor is a multiline input field, I'd like it to extend to the remaining available space on the screen, so something like
I can achieve this by adding a .frame(height:540) modifier to the TextEditor, however this hardcodes the extend is not very dynamic and thus only works on a specific device.
So the question is, how to to this in a dynamic way which works on all potential devices (different iPhones, iPad, ...).
Note: This question is similar to SwiftUI Texteditor in a form. However this only addresses the issue how to get it to show multiple lines, which can be easily achieved using the above mentioned .frame(height:X) modifier.

I don't think this is the best solution, although it works. If you want something else, try using GeometryReader with a dynamic height value stored in a CGFloat variable. (Tested on iPhone 14 Pro, 14 Pro Max and iPad 12.9 inch)
UISCREEN SOLUTION
import SwiftUI
struct ContentView: View {
#State var text = String()
var body: some View {
if #available(iOS 16, *){
Form {
Section("Section") {
Toggle("Toggle", isOn: .constant(true))
LabeledContent("TextEditor") {
TextEditor(text: $text)
}.frame(minHeight: UIScreen.main.bounds.maxY-190)
}
}
}
}
}
You can also use constant points, as the points reflect different screen sizes and resolutions: https://stackoverflow.com/a/73653966/14856451
GEOMETRY READER SOLUTION
#State var text = String()
#Environment(\.defaultMinListRowHeight) var minH
var body: some View {
if #available(iOS 16, *){
GeometryReader {geometry in
Form {
Section("Section") {
Toggle("Toggle", isOn: .constant(true))
LabeledContent("TextEditor") {
TextEditor(text: $text)
}.frame(minHeight: geometry.size.height-minH-70)
}
}
}
}
}
Notice the blue outline - that's the size of your form. As you can see in all three cases, the TextField reaches the bottom of the form without going into the safe zone, which is used to control gestures on the device (there are no gestures on the iPhone SE, so there is no safe zone either).

Related

Is it possible to override SwiftUI modifiers?

Knowing that with SwiftUI view modifiers, order matters - because each modifier is a part of a chain of modifiers, I was wondering if it was possible to reset/overwrite/override a modifier (or the whole chain?
Specifically, I'm wondering about Styles (groupBoxStyle, buttonStyle, etc). I have default styles that I want to use in 90% of my app, and a few pages will have slightly different styles for those widgets.
For example:
// Renders a button with the "light" style
Button("Hello world") {
}
.buttonStyle(LightButtonStyle())
.buttonStyle(DarkButtonStyle())
// Renders a button with the "dark" style
Button("Hello world") {
}
.buttonStyle(DarkButtonStyle())
.buttonStyle(LightButtonStyle())
In those cases, I would actually like the 2nd modifier to be used, but the 1st takes over and subsequent styles don't work.
Note: In my actual app, none of my use cases are this trivial - this is just the simplest proof of concept.
The workaround(s) I have are that I create separate LightButton and DarkButton views, but that feels very inelegant (and becomes a mess when I have 5-6 variants of each component).
Alternatively, I have a custom MyButton(myStyle: ButtonStyle = .myDefaultStyle), but since this is a forms app, there are about 50-60 locations where something like that needs to be updated (instead of applying a modifier at a top level and letting that cascade through).
Edit: I should note, where I can set a top-level style and let it cascade, that works very well and as expected (closer to the View, the modifier takes over). But, there are just some weird use cases where it would be nice to flip the script.
Generally, buttonStyle propagates to child views, so ideally you would only need to set your “house style” once on the root view of your app.
The well-known place where this fails to work is the presentation modifiers like .sheet, which do not propagate styles to the presented view hierarchy. So you will need to write your own versions of the presentation modifiers that re-apply your house style.
For example, here's a custom ButtonStyle:
struct HouseButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(20)
.background {
Capsule(style: .continuous)
.foregroundColor(.pink)
}
.saturation(configuration.isPressed ? 1 : 0.5)
}
}
And here's a cover for sheet that applies the custom button style to the presented content:
extension View {
func houseSheet<Content: View>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
#ViewBuilder content: #escaping () -> Content
) -> some View {
return sheet(isPresented: isPresented, onDismiss: onDismiss) {
content()
.buttonStyle(HouseButtonStyle())
}
}
}
We can test out whether a NavigationLink, a sheet, and a houseSheet propagate the button style:
struct ContentView: View {
#State var showingHouseSheet = false
#State var showingStandardSheet = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Navigation Push") {
ContentView()
}
Button("Standard Sheet") {
showingStandardSheet = true
}
Button("House Sheet") {
showingHouseSheet = true
}
}
.sheet(isPresented: $showingStandardSheet) {
ContentView()
}
.houseSheet(isPresented: $showingHouseSheet) {
ContentView()
}
}
}
}
Here's the root view that applies the house button style at the highest level:
struct RootView: View {
var body: some View {
ContentView()
.buttonStyle(HouseButtonStyle())
}
}
If you play with this, you'll find that both NavigationLink and houseSheet propagate the button style to the presented content, but sheet does not.

How to remove the background box gray of a picker in SwiftUI? [duplicate]

I want to change the color of the selected row.
As you may see, by default it has this light gray color.
I have no idea how to do that since I have found no way to access this row at all.
Is there any way to do that?
Demo code:
struct ContentView: View {
var data = Array(0...20).map { "\($0)" }
#State private var selected = 0
var body: some View {
VStack {
Picker("", selection: $selected) {
ForEach(0 ..< data.count) {
Text(data[$0])
}
}
}
}
}
A UIKit answer would also be welcomed since I have found no way either.
Thank you!
Using the Introspect Swift Package
It is not trivial to do in SwiftUI. I did some research however.
The underlying class behind SwiftUI's Picker goes back to UIKit and it is the UIPickerView. To customise its behaviour you need access then to the UIPickerView class and the documentation is somewhat severely lacking.
And looking at other posts on SO it seems that the way to configure the picker goes all the way to objC for example with the setValue(_ value: Any?, forKey key: String) function. I could not even find a list of those keys! And trying to set up the color of the text did not work however... I found out that I can access the subviews and could solve this background color problem.
This is just to say that I think the best way I found is using this Swift Package called Introspect.
These guys (and girls) did really an amazing job.
First install the Swift Package in your project.
Then add the code to introspect the UIPickerView. This is not part of the defaults so you need to create a custom extension like:
import Introspect
extension View {
public func introspectUIPickerView(customize: #escaping (UIPickerView) -> ()) -> some View {
return inject(UIKitIntrospectionView(
selector: { introspectionView in
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
return nil
}
return Introspect.previousSibling(containing: UIPickerView.self, from: viewHost)
},
customize: customize
))
}
}
In your code you have now access to the UIPickerView, the underlying UIKit class of your SwiftUI's Picker. Add this after the Picker:
Picker("Flavor", selection: $selectedFlavor) {
ForEach(Flavor.allCases) { flavor in
Text(flavor.rawValue.capitalized)
}
}
.pickerStyle(WheelPickerStyle()
.introspectUIPickerView { picker in
picker.subviews[1].backgroundColor = UIColor.clear
}
I used the Picker example from the Apple docs and added the .introspectUIPickerView modifier.
The result: no more grey highlighted color.
Not everything will work because the UIPickerView is really old and some things are not easily customisable. So if you want to customise other things your mileage might vary :)
Here in red with :
.introspectUIPickerView { picker in
picker.subviews[1].backgroundColor = UIColor.red
.withAlphaComponent(0.2)
}
Here would be a way to somewhat customize it.
The workaround is to just simply put a RoundedRectangle with the color of your choice perfectly underneath it.
It's not perfect, since the gray default opacity overlay is on top of that color and one has to figure out the constraints based on the frame.
struct ContentView: View {
var data = Array(0...20).map { "\($0)" }
#State private var selected = 0
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5)
.frame(width: UIScreen.main.bounds.size.width-18, height: 32)
.foregroundColor(.green)
Picker("", selection: $selected) {
ForEach(0 ..< data.count) {
Text(data[$0])
}
}
}
}
}
More simple use:
.accentColor(your color)
Picker("", selection: $selected) {
ForEach(0 ..< data.count) {
Text(data[$0])
}
}
.accentColor(.black)

AppKit's view bridged to swiftui causes view to stretch outside view/window frame

I'm having layout issues with mixed AppKit(bridged to SwiftUI)/SwiftUI views. I have simple view setup containing 2 rows where row is defined as: text view + text field from app kit bridged to swiftui.
I need my text views left of the text field to be aligned to the right, a.k.a it looks like they have trailing alignment. But since my view hierarchy defines views as rows and not as VStacks, I need customized alignment guide.
It all works fine when I replace bridged view with some SwiftUI's native view i.e Text().
Any ideas what may be causing this and how to fix it? I've tried to play around with setting content compression & hugging priorities on AppKit view or layout priorities for both SwiftUI + AppKit to SwiftUI views, without success.
Setting some specific frame size may have achieve somehow what I want, but since my texts on the left side are localized and may be arbitrarily long (and I basically want them to show them whole without truncating) and whole view may be resized horizontally too, it's not a good solution.
Here's very simplified code, that does illustrate the issue:
import AppKit
import Combine
import SwiftUI
extension HorizontalAlignment {
struct TrailingTextContent: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[HorizontalAlignment.center]
}
}
static let trailingTextContent = HorizontalAlignment(TrailingTextContent.self)
}
struct TextField: NSViewRepresentable {
#Binding private var value: String
init(value: Binding<String>) {
self._value = value
}
func makeNSView(context: Context) -> NSTextField {
let textField = NSTextField(frame: .zero)
return textField
}
func updateNSView(_ nsView: NSTextField, context: Context) {}
}
struct DetailsView: View {
var body: some View {
VStack(alignment: .trailingTextContent, spacing: 8) {
HStack(alignment: .center, spacing: 0) {
Text("Long")
.padding(.trailing, 8)
.alignmentGuide(.trailingTextContent) { d in d[HorizontalAlignment.trailing] }
TextField(value: .constant("asdasdasdasdasdasdada"))
}
HStack(alignment: .center, spacing: 0) {
Text("A bit longer text")
.padding(.trailing, 8)
.alignmentGuide(.trailingTextContent) { d in d[HorizontalAlignment.trailing] }
TextField(value: .constant("asdasdasdasdasdasdada"))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
DetailsView()
}
}
This is what it looks like:
and this is sort of how I want it to look like (+ text fields should be both same length) - I've applied some padding to whole view

Why does my SwiftUI View not update on updating of an #State var?

I am having a strange issue with an #State var not updating an iOS SwiftUI view.
I have an edit screen for themes for a small game with a NavigationView with a list of game themes. When in edit mode and I select one of these themes, I open up an editor view, passing the theme as a binding to the editor view struct.
In my editor view I then have sections that allow the user to edit properties of the theme. I do not want to use bindings to the various theme properties in my edit fields because I do not want the changes to take effect immediately. Instead, I have created #State vars for each of these properties and then use bindings to these in the edit fields. That way, I give the user the option to either cancel without and changes taking effect, or select "Done" to assign the changes back to the theme via the binding.
In order to initialise the #State vars I have an onAppear block that assign the #State vars values from the respective theme properties.
The issue I am having is that when the onAppear block is executed and the vars are assigned, the relevant edit fields are not updating!
Here is a cut-down version of my code:
struct EditorView: View {
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#Binding var theme: GameTheme
#State private var name = ""
...
var body: some View {
NavigationView {
Form {
nameSection
...
}
.navigationTitle("Edit \(theme.name)")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: cancel)
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: saveTheme)
.disabled(!canSaveTheme)
}
}
.onAppear {
name = theme.name
...
}
}
.frame(minWidth: Constants.minViewSize.width, minHeight: Constants.minViewSize.height)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
...
}
So the view gets shown an on appearing, the #State var name does correctly get assigned the value from theme.name; however, this allocation does not cause an update of the view and the value of "name" is not entered into the TextField.
Interestingly, and I do not know if this is a good thing to do, if I wrap the contents of the onAppear block in a DispatchQueue.main.async, everything works fine!
i.e.
.onAppear {
DispatchQueue.main.async {
name = theme.name
...
}
}
Does anyone have any idea as to how, within the onAppear, I can force a view refresh? Or, why the assignment to "name" does not force an update?
Thanks.
This isn't the answer per se, but I went ahead and created a new iOS project with the following code (based on your post, but I cleaned it up a bit and came up with the missing GameTheme object myself).
It's more or less the same, and shows that your posted structure does re-render.
I'm wondering if there's more to the code we can't see in your post that could be causing this.
Are you possibly setting the name state variable anywhere else in a way that could be overriding the value on load?
import SwiftUI
#main
struct TestIOSApp: App {
#State var gameTheme: GameTheme = GameTheme(name: "A game theme")
var body: some Scene {
WindowGroup {
ContentView(theme: $gameTheme)
}
}
}
struct GameTheme {
var name:String;
}
struct ContentView: View {
#Binding var theme:GameTheme;
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#State private var name = "DEFAULT SHOULD NOT BE DISPLAYED"
var body: some View {
NavigationView {
Form {
nameSection
}
.navigationTitle("Edit \(theme.name)")
.onAppear {
name = theme.name
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: {})
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: {})
}
}
.frame(maxWidth:.infinity, maxHeight: .infinity)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
}
I seem to have solved my problem with an init(). I created init(theme: Binding<GameTheme>) and then within the init assigned the theme via _theme = theme and then assigned the name via _name = State(initialValue: theme.name.wrappedValue).

Event that fires as Swiftui Picker scrolls through its list

I have a SwiftUI Picker and I want to update another view based on its value as the user scrolls through the list. By passing a #Binding to the picker I can update when the user stops on a selected item... but I haven't been able to find an event that fires as the list scrolls.
With the old UIPickerView you can use titleForRow to hook into this as mentioned in this answer. I'm looking for something similar that works with SwiftUI's Picker.
Here's an extremely simple SwiftUI snippet that demonstrates the problem with the default behavior that I'm trying to find a way to work around:
import SwiftUI
struct ContentView: View {
#State var selected:Int = 0
var toDisplay:String {
get {
return "Selected: \(selected)"
}
}
var body: some View {
return VStack {
Text(toDisplay)
Form {
Section{
Picker(selection: $selected, label: Text("")) {
ForEach(0..<100) {
Text("Item \($0)").tag($0)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: 350, height: 250, alignment: .center)
}
}
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
If you run this, you can see that the Selected: # text at the top doesn't update until the picker completely stops:
If you’re looking for a way to achieve this using only SwiftUI’s Picker, then I believe you’re out of luck. Because of how SwiftUI is designed, you really don’t have access to the underlying functionality of these views in the same way you would with UIKit. If you think that using titleForRow with a UIPickerView is a viable solution, then your best bet would be to use UIViewRepresentable to port UIPickerView over to use with SwiftUI.