SwiftUI view with multiple ViewBuilder - swiftui

I have a view that represents a row in a cell that looks like this
This works well but the three horizontal elements (Image, Title/Subtitle, Image) are hardcoded to their respective types.
I would like to have a generic ThreeItemView that can takes 3 Views of any type and arrange them as shown. This would allow me to reuse the same container layout with any other views types.
I created a view that takes three #ViewBuilders:
import Foundation
import SwiftUI
struct ThreeItemView<Start: View, Main: View, End: View>: View {
let start: () -> Start
let main: () -> Main
let end: () -> End
init(#ViewBuilder start: #escaping() -> Start,
#ViewBuilder main: #escaping() -> Main,
#ViewBuilder end: #escaping() -> End) {
self.start = start
self.main = main
self.end = end
}
var body: some View {
return HStack {
start()
main()
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
end()
}
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: 60, alignment: .leading)
}
}
struct ThreeItemContainer_Previews: PreviewProvider {
static var previews: some View {
ThreeItemView(start: {
Image(systemName: "envelope.fill")
}, main: {
Text("Main")
}, end: {
Image(systemName: "chevron.right")
})
}
}
This works as expected but the API is a bit .. cumbersome. What would be a way to make the usage of the ThreeItemView easier?

If you mean notation like the following
ThreeItemView {
Start {
Image(systemName: "envelope.fill")
}
Main {
Text("Main")
}
End {
Image(systemName: "chevron.right")
}
}
then find below modified your module
typealias Start<V> = Group<V> where V:View
typealias Main<V> = Group<V> where V:View
typealias End<V> = Group<V> where V:View
struct ThreeItemView<V1, V2, V3>: View where V1: View, V2: View, V3: View {
private let content: () -> TupleView<(Start<V1>, Main<V2>, End<V3>)>
init(#ViewBuilder _ content: #escaping () -> TupleView<(Start<V1>, Main<V2>, End<V3>)>) {
self.content = content
}
var body: some View {
let (start, main, end) = self.content().value
return HStack {
start
main
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
end
}
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: 60, alignment: .leading)
}
}
struct ThreeItemContainer_Previews: PreviewProvider {
static var previews: some View {
ThreeItemView {
Start {
Image(systemName: "envelope.fill")
}
Main {
Text("Main")
}
End {
Image(systemName: "chevron.right")
}
}
}
}

Related

How do I make two separate calls to an Imagepicker in SwiftUI?

I want users to be able to upload two separate images to two different parts of the same view.
I'm able to get the first image to show correctly at the top. But whenever the user adds the second image, the image at the top is updated again instead of the image at the bottom.
Screenshot
Below is my code. Any help would be appreciated!
ImagePicker
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
#Binding var image: UIImage?
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
}
ContentView
import SwiftUI
struct ContentView: View {
#State private var firstImage: Image? = Image("PlaceholderImage")
#State private var secondImage: Image? = Image("PlaceholderImage")
#State private var inputImage1: UIImage?
#State private var inputImage2: UIImage?
#State private var showingImagePicker = false
var body: some View {
VStack{
Form {
Section(header: Text("First Image")){
firstImage!
.resizable()
.aspectRatio(contentMode: .fit)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 200, alignment: .center)
.clipShape(Rectangle())
.onTapGesture { self.showingImagePicker = true }
.sheet(isPresented: $showingImagePicker, onDismiss: loadImage1) {
ImagePicker(image: self.$inputImage1)
}
}
Section(header: Text("Second Image")){
secondImage!
.resizable()
.aspectRatio(contentMode: .fit)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 200, alignment: .center)
.clipShape(Rectangle())
.onTapGesture { self.showingImagePicker = true }
.sheet(isPresented: $showingImagePicker, onDismiss: loadImage2) {
ImagePicker(image: self.$inputImage2)
}
}
}
}
}
func loadImage1() {
guard let inputImage = inputImage1 else { return }
firstImage = Image(uiImage: inputImage)
}
func loadImage2() {
guard let inputImage = inputImage2 else { return }
secondImage = Image(uiImage: inputImage)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The root cause of the problem is using sheet(isPresented:) which can be problematic on iOS 14 because it loads the sheet content of the first render of it (in your case, the ImagePicker for the first image) and then doesn't update as one might expect (see SwiftUI #State and .sheet() ios13 vs ios14 for example).
The solution is to use sheet(item:). In your case, this also involved some other refactoring to get things to work as expected. Here's what I came up with:
struct ContentView: View {
#State private var inputImage1: UIImage?
#State private var inputImage2: UIImage?
#State private var activeImagePicker : ImagePickerIdentifier? = nil
enum ImagePickerIdentifier : String, Identifiable {
case picker1, picker2
var id : String {
return self.rawValue
}
}
var image1 : Image {
if let inputImage1 = inputImage1 {
return Image(uiImage: inputImage1)
} else {
return Image("Placeholder")
}
}
var image2 : Image {
if let inputImage2 = inputImage2 {
return Image(uiImage: inputImage2)
} else {
return Image("Placeholder")
}
}
var body: some View {
VStack{
Form {
Section(header: Text("First Image")){
image1
.resizable()
.aspectRatio(contentMode: .fit)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 200, alignment: .center)
.clipShape(Rectangle())
.onTapGesture { self.activeImagePicker = .picker1 }
}
Section(header: Text("Second Image")) {
image2
.resizable()
.aspectRatio(contentMode: .fit)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 200, alignment: .center)
.clipShape(Rectangle())
.onTapGesture { self.activeImagePicker = .picker2 }
}
}
.sheet(item: $activeImagePicker) { picker in
switch picker {
case .picker1:
ImagePicker(image: $inputImage1)
case .picker2:
ImagePicker(image: $inputImage2)
}
}
}
}
}
Note that I got rid of your onDismiss function, because it was unnecessary here, but if you did need it for your real implementation, I'd suggest using onDisappear on the individual ImagePickers, which will let you differentiate which one is being dismissed.

SwiftUI not centering when in ZStack

I am trying to put together a view that consists of a top header view, a bottom content view, and a view that sits on top centered on the line splitting the two views. I figured out I need an alignment guide within a ZStack to position the middle view but I am having problems getting the items in the lower content view centered without a gap.
This code:
extension VerticalAlignment {
struct ButtonMid: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
return context[.bottom]
}
}
static let buttonMid = VerticalAlignment(ButtonMid.self)
}
struct ContentView: View {
var body: some View {
VStack {
ZStack(alignment: Alignment(horizontal: .center, vertical: .buttonMid)) {
HeaderView()
.frame(maxWidth: .infinity, minHeight: 200, idealHeight: 200, maxHeight: 200, alignment: .topLeading)
// BodyView()
// .alignmentGuide(.buttonMid, computeValue: { dimension in
// return dimension[VerticalAlignment.top]
// })
Color.red
.frame(width: 380, height: 50, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
.alignmentGuide(.buttonMid, computeValue: { dimension in
return dimension[VerticalAlignment.center]
})
}
BodyView()
}
.edgesIgnoringSafeArea(.top)
}
}
struct HeaderView: View {
var body: some View {
Color.green
}
}
struct BodyView: View {
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
BodyContent()
Spacer()
}
Spacer()
}
.background(Color.blue)
}
}
struct BodyContent: View {
var body: some View {
VStack {
Text("Line 1")
Text("Line 2")
Text("Line 3")
}
}
}
give you this:
which centers the lower content they way I want it however it leaves a gap between the upper and lower views. If I uncomment the BodyView code in the ZStack and comment it out in the VStack like so:
struct ContentView: View {
var body: some View {
VStack {
ZStack(alignment: Alignment(horizontal: .center, vertical: .buttonMid)) {
HeaderView()
.frame(maxWidth: .infinity, minHeight: 200, idealHeight: 200, maxHeight: 200, alignment: .topLeading)
BodyView()
.alignmentGuide(.buttonMid, computeValue: { dimension in
return dimension[VerticalAlignment.top]
})
Color.red
.frame(width: 380, height: 50, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
.alignmentGuide(.buttonMid, computeValue: { dimension in
return dimension[VerticalAlignment.center]
})
}
// BodyView()
}
.edgesIgnoringSafeArea(.top)
}
}
gives you:
which leaves the content uncentered. How can I keep it centered? I tried putting it in a GeometryReader and that had the same results.
You don't need a custom VerticalAlignment. Instead you can put the middle view as an overlay and align it to the top border of the bottom view:
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
HeaderView()
.frame(height: 200)
BodyView()
.overlay(
Color.red
.frame(width: 380, height: 50)
.alignmentGuide(.top) { $0[VerticalAlignment.center] },
alignment: .top
)
}
.edgesIgnoringSafeArea(.top)
}
}

SwiftUI Tabbar persists navigationBarTitle position in tab's views

How can I stop a tab's scrollview's offset being affect by other tab's offset?
I don't want to force the scroll view to the top every time you show a new tab, but just want the new tabs to be not affected by the scroll position of the last tab I viewed.
import SwiftUI
enum Tab {
case First, Second, Third
var title: String {
switch self {
case .First:
return "First"
case .Second:
return "Second"
case .Third:
return "Third"
}
}
}
struct ContentView: View {
#State var selectedTab = Tab.First
var body: some View {
NavigationView {
TabView(selection: $selectedTab) {
FirstView()
.tabItem {
Text("First")
}.tag(Tab.First)
SecondView()
.tabItem {
Text("Second")
}.tag(Tab.Second)
ThirdView()
.tabItem {
Text("Third")
}.tag(Tab.Third)
}.navigationBarTitle(selectedTab.title, displayMode: .automatic)
.navigationBarHidden(false)
}
}
}
struct FirstView: View {
let data = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
ScrollView(showsIndicators: true) {
VStack {
ForEach(data, id: \.self) { item in
Text("\(item)")
.frame(minWidth: 0, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 100, maxHeight: .infinity, alignment: .center)
}
}
}
}
}
struct SecondView: View {
let data = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
ScrollView(showsIndicators: true) {
VStack {
ForEach(data, id: \.self) { item in
Text("\(item)")
.frame(minWidth: 0, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 100, maxHeight: .infinity, alignment: .center)
}
}
}
}
}
struct ThirdView: View {
let data = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
ScrollView(showsIndicators: true) {
VStack {
ForEach(data, id: \.self) { item in
Text("\(item)")
.frame(minWidth: 0, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 100, maxHeight: .infinity, alignment: .center)
}
}
}
}
}
It is because you use one NavigationView, so it preserves own state. Make NavigationView independent for each tab.
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State var selectedTab = Tab.First
var body: some View {
TabView(selection: $selectedTab) {
NavigationView {
FirstView()
.navigationBarTitle(Tab.First.title)
}
.tabItem {
Text("First")
}.tag(Tab.First)
NavigationView {
SecondView()
.navigationBarTitle(Tab.Second.title)
}
.tabItem {
Text("Second")
}.tag(Tab.Second)
NavigationView {
ThirdView()
.navigationBarTitle(Tab.Third.title)
}
.tabItem {
Text("Third")
}.tag(Tab.Third)
}
}
}

Why does this simple SwiftUI view modifier with transition produce a crash?

I wrote this very simple .modal View modifier in SwiftUI.
When it is opened, I press close and before the transition finished open again, I get the following crash:
Gesture: Failed to receive system gesture state notification before next touch
Sample code:
import SwiftUI
struct ContentView: View {
#State var show = false
var body: some View {
VStack {
Button("Open") {
withAnimation {
self.show.toggle()
}
}.disabled(self.show) // doesn't help
}.modal(isShowing: self.$show) {
Button("Close") {
withAnimation {
self.show.toggle()
}
}
}
}
}
extension View {
func modal<C>(isShowing: Binding<Bool>, #ViewBuilder content: #escaping () -> C) -> some View where C: View {
self.modifier(ModalView(isShowing: isShowing, content: content))
}
}
struct ModalView<C>: ViewModifier where C: View {
#Binding var isShowing: Bool
let content: () -> C
func body(content: Content) -> some View {
ZStack {
content.zIndex(0)
if self.isShowing {
self.content()
.background(Color.primary.colorInvert())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.transition(.move(edge: .bottom))
.zIndex(1)
}
}
}
}
Can someone explain how I can prevent this? It seems that the Bool responsible for toggling is set before the animation has finished. Is this by design?
Yes, you need to disable below content (as you introduce modality) but in a bit different place
Here is fixed variant. Tested with Xcode 12 / iOS 14
struct ModalView<C>: ViewModifier where C: View {
#Binding var isShowing: Bool
let content: () -> C
#State private var interactive: Bool // track state
init(isShowing: Binding<Bool>, #ViewBuilder content: #escaping () -> C) {
self._isShowing = isShowing
self._interactive = State(initialValue: !isShowing.wrappedValue)
self.content = content
}
func body(content: Content) -> some View {
ZStack {
content.zIndex(0).disabled(!interactive) // disable here !!
if self.isShowing {
self.content()
.background(Color.primary.colorInvert())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.transition(.move(edge: .bottom))
.zIndex(1)
.onAppear { self.interactive = false } // << !!
.onDisappear { self.interactive = true } // << !!
}
}
}
}

Rectangle overlay on hovering a custom button

I'm trying to define a custom button style where on hover a rectangle pops up around that button.
struct CustomButtonStyle: ButtonStyle {
#State private var isOverButton = false
func makeBody(configuration: Self.Configuration) -> some View {
ZStack {
configuration.label
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color("Frost1"))
}
.padding(3)
.onHover { over in
self.isOverButton = over
print("isOverButton:", self.isOverButton, "over:", over)
}
.overlay(VStack {
if self.isOverButton {
Rectangle()
.stroke(Color("Frost1"), lineWidth: 2)
} else {
EmptyView()
}
})
}}
The print line shows me that setting the variable "isOverButton" is not working. Which type of variable state am I supposed to use that can be updated from "onHover" and updates "overlay"?
Here is a solution. Tested with Xcode 11.4.
struct TestOnHoverButton: View {
var body: some View {
Button("Button") {}
.buttonStyle(CustomButtonStyle())
}
}
struct CustomButtonStyle: ButtonStyle {
private struct CustomButtonStyleView<V: View>: View {
#State private var isOverButton = false
let content: () -> V
var body: some View {
ZStack {
content()
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.blue)
}
.padding(3)
.onHover { over in
self.isOverButton = over
print("isOverButton:", self.isOverButton, "over:", over)
}
.overlay(VStack {
if self.isOverButton {
Rectangle()
.stroke(Color.blue, lineWidth: 2)
} else {
EmptyView()
}
})
}
}
func makeBody(configuration: Self.Configuration) -> some View {
CustomButtonStyleView { configuration.label }
}
}