Implement a collection view SwiftUI - swift

How do I implement a collection view (that is, rectangles arranged in a grid) in SwiftUI?
I've tried ForEach-ing over a range of values, then using the counter as an index for an Array but have had numerous problems so are looking for a different way to achieve a collection view.
The cells to wrap over multiple rows. I have a SegmentedControl which is used to set how many cells to squeeze on one row.
Here is what I have:
VStack { // Multiple rows
ForEach((0..<requiredRows).identified(by: \.self)) { row in // Iterate over (previously calculated) required row count
HStack(spacing: 50)) { // Row
ForEach((0..<self.maximumOnXAxis).identified(by: \.self)) { cell in // Iterate over available cells
MyView(
width: calculatedSize,
height: calculatedSize / 2.0,
colour: Color(
red: lst[row * self.maximumOnXAxis][0],
green: lst[row * self.maximumOnXAxis][1],
blue: lst[row * self.maximumOnXAxis][2]
)
)
}
}
}
And all of the above is inside a ScrollView.
The current problem is a 'Could not type check in reasonable time ...', which happened after I add the colour: Colour(...) parameter to MyView.
Example of desired effect:

SwiftUI does support a ScrollView which you can use to present collections, although you would have to manage your own grids. The GeometryReader method might be helpful, it allows you to easily scale the "cells" to the width and length of the parent view.
For reference, I've created a simple horizontal scroll view. You can use this same logic to create a grid view:
import SwiftUI
import PlaygroundSupport
struct MyView : View {
let items: [String] = ["1", "2", "3", "4", "5"]
var body : some View {
GeometryReader { geometry in
ScrollView {
HStack {
ForEach(self.items.identified(by: \.self)) { row in
Text(row)
.padding(geometry.size.width / 5)
.background(Color.green)
}
}
}.background(Color.white)
}
.padding()
.background(Color.red)
}
}
let vc = UIHostingController(rootView: MyView())
PlaygroundPage.current.liveView = vc

Maybe this is what you are looking for : https://github.com/pietropizzi/GridStack
The only missing feature is that you cannot do "masonry" like layout

Related

Weird behavior when using SwiftUI View as accessory view for NSSavePanel

I'm trying to use a view written in SwiftUI as an accessory view of my NSSavePanel but I struggled to get it working properly.
Here's the implementation for my SwiftUI view:
struct ExportAccessoryView: View {
enum ExportFileType: String, Identifiable {
// ... enum declaration
}
#State var selectedExportFileType: ExportFileType = .png
#State var resolution = 256.0
#Binding var selectedFileTypeBinding: ExportFileType
#Binding var resolutionBinding: Double
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker(selection: $selectedExportFileType, label: Text("Format:")) {
Text("PDF").tag(ExportFileType.pdf)
// ... other items
}
.frame(width: 170)
.padding(.leading, 21)
if [ExportFileType.png, ExportFileType.jpeg, ExportFileType.tiff].contains(selectedExportFileType) {
HStack {
Slider(value: $resolution, in: 128...1024,
label: { Text("Resolution:") })
.frame(width: 200)
Text("\(Int(resolution))")
.frame(width: 40, alignment: .leading)
.padding(.leading, 5)
}
}
}
.padding(10)
.onChange(of: selectedExportFileType) { newValue in
self.selectedFileTypeBinding = newValue
}
.onChange(of: resolution) { newValue in
self.resolutionBinding = newValue
}
}
}
Here's how I implemented my save panel:
class DocumentWindow: NSWindowController {
var exportFileType: ExportAccessoryView.ExportFileType = .pdf
var resolution = 256.0
lazy var exportPanel: NSSavePanel = {
let savePanel = NSSavePanel()
savePanel.message = "Specify where and how you wish to export..."
savePanel.nameFieldLabel = "Export As:"
savePanel.canCreateDirectories = true
savePanel.isExtensionHidden = false
savePanel.showsTagField = true
let fileTypeBinding = Binding {
return self.exportFileType
} set: { newValue in
self.exportFileType = newValue
// update file extension
self.exportPanel.allowedContentTypes = [UTType(newValue.rawValue)!]
}
let resolutionBinding = Binding {
return self.resolution
} set: { newValue in
self.resolution = newValue
}
let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
resolutionBinding: resolutionBinding)
let exportAccessoryView = NSHostingController(rootView: accessoryView)
savePanel.accessoryView = exportAccessoryView.view
savePanel.allowedContentTypes = [UTType(self.exportFileType.rawValue)!]
return savePanel
}()
}
The save panel is presented by invoking beginSheetModal(for:completionHandler:).
It has no problem displaying but the accessory view is exhibiting some bizarre behavior: it seems to be doing its own thing at random (I sought for patterns but I failed to do so).
Sometimes it works properly, sometimes it becomes unclickable (but the function is still accessible via switch control using TAB). The alignment is always different from the last time I expanded/collapsed or opened/closed the panel: sometimes it's left aligned, sometimes it's centered (even if I have explicitly opted for .leading for alignment).
I have absolutely no idea what's going on. I don't know if this is an issue with SwiftUI+AppKit or is it that I'm doing it all wrong, which is highly likely since I'm a total newbie in SwiftUI. What should I do to get it working properly?
I remembered from back in the days when I was using XIB for implementing an accessory view: I used to embed the controls within an NSView and then set up constraints to make it work. So I applied the same idea here of embedding the NSHostingView's view within a custom NSView and after tweaking it for a bit, I made it work:
lazy var exportPanel: NSSavePanel = {
// ... setting up save panel
// instantiate SwiftUI view and its hosting controller
let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
resolutionBinding: resolutionBinding)
let exportAccessoryView = NSHostingController(rootView: accessoryView)
// embed the SwiftUI in a custom view
let customView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 60))
customView.addSubview(exportAccessoryView.view)
// use my own constraints
exportAccessoryView.view.translatesAutoresizingMaskIntoConstraints = false
// top and bottom clipped to custom view
exportAccessoryView.view.topAnchor.constraint(equalTo: customView.topAnchor).isActive = true
exportAccessoryView.view.bottomAnchor.constraint(equalTo: customView.bottomAnchor).isActive = true
// leading and trailing spaces can stretch as far as they need to be, hence ≥0
exportAccessoryView.view.leadingAnchor.constraint(greaterThanOrEqualTo: customView.leadingAnchor).isActive = true
exportAccessoryView.view.trailingAnchor.constraint(greaterThanOrEqualTo: customView.trailingAnchor).isActive = true
// center the SwiftUI view horizontal within custom view
exportAccessoryView.view.centerXAnchor.constraint(equalTo: customView.centerXAnchor).isActive = true
// usually fixed width and height
// can be flexible when SwiftUI view is dynamic
exportAccessoryView.view.widthAnchor.constraint(equalToConstant: customView.frame.width).isActive = true
exportAccessoryView.view.heightAnchor.constraint(greaterThanOrEqualToConstant: customView.frame.height).isActive = true
savePanel.accessoryView = customView
// ... additional setup
return savePanel
}()
Now it works perfectly as expected. Don't know if this is the "proper way" to implement such integration.

ScrollView or List, How to make a particular scrollable view (SwiftUI)

So I am trying to make this List or ScrollView in SwiftUI (not fully iOS 14 ready yet, I have to use the old api, so I can't use ScrollViewReader or other fantasy from iOS 14+ SwiftUI api).
The goal is to have only a specific number of rows visible on the view and be able to center the last one.
Images always make it easier to explain. So it should look somewhat like this.
the first two rows have a specific color applied to them, the middle one also but has to be center vertically. then there are eventually 2 more rows under but they are invisible at the moment and will be visible by scrolling down and make them appear.
The closest example i have of this, is the Apple Music Lyrics UI/UX if you are familiar with it.
I am not sure how to approach this here. I thought about create a List that will have a Text with a frame height defined by the height of the view divided by 5. But then I am not sure how to define a specific color for each row depending of which one is it in the view at the moment.
Also, It would be preferable if I can just center the selected row and let the other row have their own sizes without setting one arbitrary.
Banging my head on the walls atm, any help is welcomed! thank you guys.
Edit 1:
Screenshot added as a result from #nicksarno answer. Definitely looking good. The only noted issue atm is that it highlights multiple row with the same color instead of one.
Edit 2:
I did some adjustment to the code #nicksarno published.
let middleScreenPosition = geometryProxy.size.height / 2
return ScrollView {
VStack(alignment: .leading, spacing: 20) {
Spacer()
.frame(height: geometryProxy.size.height * 0.4)
ForEach(playerViewModel.flowReaderFragments, id: \.id) { text in
Text(text.content) // Outside of geometry ready to set the natural size
.opacity(0)
.overlay(
GeometryReader { geo in
let midY = geo.frame(in: .global).midY
Text(text.content) // Actual text
.font(.headline)
.foregroundColor( // Text color
midY > (middleScreenPosition - geo.size.height / 2) && midY < (middleScreenPosition + geo.size.height / 2) ? .white :
midY < (middleScreenPosition - geo.size.height / 2) ? .gray :
.gray
)
.colorMultiply( // Animates better than .foregroundColor animation
midY > (middleScreenPosition - geo.size.height/2) && midY < (middleScreenPosition + geo.size.height/2) ? .white :
midY < (middleScreenPosition - geo.size.height/2) ? .gray :
.clear
)
.animation(.easeInOut)
}
)
}
Spacer()
.frame(height: geometryProxy.size.height * 0.4)
}
.frame(maxWidth: .infinity)
.padding()
}
.background(
Color.black
.edgesIgnoringSafeArea(.all)
)
Now it is closer to what I am looking for. Also, I added some Spacer (not dynamic yet) that will allow to center the text if needed in the middle. Still need to figure how to put the text in the middle when selected one of the element.
Here's how I would do it. First, add a GeometryReader to get the size of the entire screen (screenGeometry). Using that, you can set up the boundaries for your text modifiers (upper/lower). Finally, add a GeoemteryReader behind each of the Text() and (here's the magic) you can use the .frame(in: global) function to find its current location in the global coordinate space. We can then compare the frame coordinates to our upper/lower boundaries.
A couple notes:
The current boundaries are at 40% and 60% of the screen.
I'm currently using the .midY coordinate of the Text geometry, but you can also use .minY or .maxY to get more exact.
The natural behavior of a GeometryReader sizes to the smallest size to fit, so to keep the natural Text sizes, I added a first Text() with 0% opacity (for the frame) and then added the GeometryReader + the actual Text() as an overlay.
Lastly, the .foregroundColor doesn't really animate, so I also added a .colorMultiply on top, which does animate.
Code:
struct ScrollViewPlayground: View {
let texts: [String] = [
"So I am trying to make this List or ScrollView in SwiftUI (not fully iOS 14 ready yet, I have to use the old api, so I can't use ScrollViewReader or other fantasy from iOS 14+ SwiftUI api). The goal is to have only a specific number of rows visible on the view and be able to center the last one.",
"Images always make it easier to explain. So it should look somewhat like this.",
"the first two rows have a specific color applied to them, the middle one also but has to be center vertically. then there are eventually 2 more rows under but they are invisible at the moment and will be visible by scrolling down and make them appear.",
"The closest example i have of this, is the Apple Music Lyrics UI/UX if you are familiar with it.",
"I am not sure how to approach this here. I thought about create a List that will have a Text with a frame height defined by the height of the view divided by 5. But then I am not sure how to define a specific color for each row depending of which one is it in the view at the moment.",
"Also, It would be preferable if I can just center the selected row and let the other row have their own sizes without setting one arbitrary.",
"Banging my head on the walls atm, any help is welcomed! thank you guys."
]
var body: some View {
GeometryReader { screenGeometry in
let lowerBoundary = screenGeometry.size.height * 0.4
let upperBoundary = screenGeometry.size.height * (1.0 - 0.4)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
ForEach(texts, id: \.self) { text in
Text(text) // Outside of geometry ready to set the natural size
.opacity(0)
.overlay(
GeometryReader { geo in
let midY = geo.frame(in: .global).midY
Text(text) // Actual text
.font(.headline)
.foregroundColor( // Text color
midY > lowerBoundary && midY < upperBoundary ? .white :
midY < lowerBoundary ? .gray :
.gray
)
.colorMultiply( // Animates better than .foregroundColor animation
midY > lowerBoundary && midY < upperBoundary ? .white :
midY < lowerBoundary ? .gray :
.clear
)
.animation(.easeInOut)
}
)
}
}
.frame(maxWidth: .infinity)
.padding()
}
.background(
Color.black
.edgesIgnoringSafeArea(.all)
)
}
}
}
struct ScrollViewPlayground_Previews: PreviewProvider {
static var previews: some View {
ScrollViewPlayground()
}
}

SwiftUI: Synchronise 2 Animation Functions (CAMediaTimingFunction and Animation.timingCurve)

I was using CAMediaTimingFunction (for a window) and Animation.timingCurve (for the content) with the same Bézier curve and time to deal with the resizing. However, there can be a very small time difference and therefore it caused the window to flicker irregularly. Just like this (GitHub repository: http://github.com/toto-minai/DVD-Player-Components ; original design by 7ahang):
Red background indicates the whole window, while green background indicates the content. As you can see, the red background is visible at certain points during the animation, which means that the content is sometimes smaller than the window, so it generates extra small spaces and causes unwanted shaking.
In AppDelegate.swift, I used a custom NSWindow class for the main window and overrode setContentSize():
class AnimatableWindow: NSWindow {
var savedSize: CGSize = .zero
override func setContentSize(_ size: NSSize) {
if size == savedSize { return }
savedSize = size
NSAnimationContext.runAnimationGroup { context in
context.timingFunction = CAMediaTimingFunction(controlPoints: 0, 0, 0.58, 1.00) // Custom Bézier curve
context.duration = animationTime // Custom animation time
animator().setFrame(NSRect(origin: frame.origin, size: size), display: true, animate: true)
}
}
}
In ContentView.swift, the main structure (simplified):
struct ContentView: View {
#State private var showsDrawer: Bool = false
var body: some View {
HStack {
SecondaryAdjusters() // Left panel
HStack {
Drawer() // Size-changing panel
.frame(width: showsDrawer ? 175 : 0, alignment: .trailing)
DrawerBar() // Right bar
.onTapGesture {
withAnimation(Animation.timingCurve(0, 0, 0.58, 1.00, duration: animationTime)) { // The same Bézier curve and animation time
showsDrawer.toggle()
}
}
}
}
}
}
The animated object is the size-changing Drawer() in the middle. I suppose that it is because the different frame rate of two methods. I'm wondering how to synchronise the two functions, and whether there's another way to implement drawer-like View in SwiftUI. You could clone the repository here. Thanks for your patient.

SwiftUI - disclosure group expanding behaviour - keep top item static

I'm trying to create a drop down menu using swift UI. I'm actually implementing this into a UIKit project.
The basic functionality I am going for should be that the user clicks on a label with a certain data unit on it and a list expands with other units of measure that can be selected.
Here is my SwiftUI code:
import SwiftUI
#available(iOS 14.0.0, *)
struct DataUnitDropDown: View {
var units = ["ltr", "usg", "impg"]
#State private var selectedDataUnit = 0
#State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 15) {
DisclosureGroup(units[selectedDataUnit], isExpanded: $isExpanded) {
VStack {
ForEach(0 ..< units.count, id: \.self) { index in
Text("\(units[index])")
.font(.title3)
.padding(.all)
.onTapGesture {
self.selectedDataUnit = index
withAnimation {
self.isExpanded.toggle()
}
}
}
}
}
}
}
}
So I have a couple of questions here. Firstly, ideally I wanted to place this into an existing horizontal UIStackView. However, the issue I have is that obviously once the dropdown expands, it increases the height of the stack, which is not what I want. Here is a screen shot of the component (see 'ltr'):
When expanded in the stackView:
So I reluctantly placed it in the main view adding some autolayout constraints:
if #available(iOS 14.0.0, *) {
let controller = UIHostingController(rootView: DataUnitDropDown())
controller.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(controller.view)
controller.view.leadingAnchor.constraint(equalTo: refuelInfoView.actualUpliftVolume.trailingAnchor).isActive = true
controller.view.centerYAnchor.constraint(equalTo: refuelInfoView.actualUpliftVolume.centerYAnchor).isActive = true
}
But now when I expand, the whole menu shifts up:
Clearly not what I am looking for.
So I suppose my question is 2-fold:
1- Is there any way I can incorporate this element into an existing stack view without the stack increasing in height when the dropdown is expanded? (I guess not!)
2- If I have to add this to the main view rather than the stack, how can I stop the entire menu shifting up when it expands?
Update:
I have amended the code as follows:
if #available(iOS 14.0.0, *) {
let controller = UIHostingController(rootView: DataUnitDropDown())
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.clipsToBounds = false
controller.view.heightAnchor.constraint(equalToConstant: 22).isActive = true
controller.view.topAnchor.constraint(equalTo: topAnchor).isActive = true
stackSubviews = [inputField, controller.view]
}
Now, without the topAnchor constraint this works perfectly, but I do need this constraint. But I get an error due to the views. not being in the same hierarchy
I assume instead of
controller.view.centerYAnchor.constraint(equalTo:
refuelInfoView.actualUpliftVolume.centerYAnchor).isActive = true
you need
controller.view.topAnchor.constraint(equalTo:
refuelInfoView.actualUpliftVolume.bottomAnchor).isActive = true

How can I make the quizView appear in green onTapGesture when the user clicks the right answer

I am making a language app and want the correct answer to appear in green once the user taps on it but I am struggling to do so.
This is the part of the code where I want to achieve this behavior.
Grid(viewModel.answers) { answer in
//how can i change the ui of quizView(answer) that is being tapped inside the closure
quizView(answer: answer).onTapGesture {
if self.viewModel.chooseAnswer(answer: answer){
self.dim.toggle()
self.disabled.toggle()
print("a")
}
}
.opacity(self.dim ? 0.4 : 1.0)
.animation(.easeInOut(duration: 1.0))
.disabled(self.disabled)
}
}
.edgesIgnoringSafeArea([.top])
}
}}
struct quizView: View {
var answer: LearnModel.Answer
var body: some View{
GeometryReader { geometry in
ZStack{
answerBubble
Text(self.answer.word.EnglishWord)
.multilineTextAlignment(.center)
.foregroundColor(Color.white)
}
}
}}
var answerBubble: some View {
RoundedRectangle(cornerRadius: 20)
.fill(Color.black)
.shadow(color: Color.black, radius: 20, y: 5)
.opacity(0.2)
.padding()
}
Grid(viewModel.answers) - Creates the Grid and Gridlayout for the answer bubbles.
Screenshot of the UI
Your answerBubble is the view that you're trying to update. So, this view needs to have some type of parameter that changes its fill color.
Let's assume that the background turns green if the user taps the right answer, keeps its black color if it isn't tapped, and turns red otherwise. I would create a stateful list of tapped answers in your main view:
#State private var tappedAnswers = [LearnModel.Answer]()
This will create an empty list of answers that you can keep track of. Once the user taps the quizView, then you should add it to the list if it isn't there already (you could really do this with a Set, but I'm sticking with arrays for its familiarity).
quizView(answer: answer).onTapGesture {
// Add the new answer to the list of tapped answers
if !tappedAnswers.contains(answer) {
tappedAnswers.append(answer)
}
// Do any animation work you need in here
}
I'm not familiar with the rest of your application, but you need to store the correct answer and compare that to the selected answers. We'll call this correctAnswer for now.
Now you can add a function that determines the appropriate fill color for each answerBubble:
func colorFor(answer: LearnModel.Answer, correctAnswer: LearnModel.Answer) -> Color {
if answer == correctAnswer {
// Check for correct answer right away; order matters here.
return Color.green
} else if tappedAnswers.contains(correctAnswer) {
// The user tapped the answer, but it wasn't correct.
return Color.red
} else {
// The user hasn't tapped this answer yet.
return Color.black
}
}
Finally, add a parameter to answerBubble that accepts a fillColor variable that lets you pass in this newly created color method (although you'll need to make this a proper SwiftUI view).
struct AnswerBubble: View {
let fillColor: Color
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(fillColor)
.shadow(color: Color.black, radius: 20, y: 5)
.opacity(0.2)
.padding()
}
}
Also make sure that you pass the fillColor you get to quizView since it needs to pass it to answerBubble. Now you can use this inside your ForEach loop. Once you've added the parameters, it should look like:
quizView(
answer: answer,
fillColor: self.colorFor(
answer: answer,
correctAnswer: correctAnswer
)
)
(Also, consider renaming quizView to QuizView since it's not a variable)
I don't know the details of your LearnModel, but you should be able to adapt this as you need.