How to generated unique id in ForEach with [Int] - swiftui

I am using a ForEach to construct a number of views based on an array of integers.
This array of integers can contain duplicate values. In addition, since an Int is not Identifiable I have to use a key path. When using \.self however, I get bad results (the colors of the rectangle are not correctly set) due to the duplicate values.
What would be the easiest way to create unique identifiers or keypaths to fix this?
#ViewBuilder
func stackFor(bar: Bar, scale: CGFloat) -> some View {
//sample data
let values = [4,4,5,6]
var index = 0
//create stack of view
//==> since an Int is not identifiable I have to use keypath
// however \.self is not unique as we have 2x the value 4 in the values
// this leads bad results because there are duplica ids
ForEach(values, id: \.self) { value in
Rectangle()
.fill(colorForStackedBar(index: index))
.frame(height: heightFor(value: value, scale: scale) )
Rectangle()
.fill(Color.clear)
.frame(height: 1 )
//the let _ = ... is just a "hack" for being able to update the index in #ViewBuilder
let _ = index = index + 1
}
}
func colorForStackedBar(index: Int) -> Color {
return barColors[safeIndex: index] ?? .red
}
func heightFor(value: Int, scale: CGFloat) -> CGFloat {
let height = CGFloat(value) * scale
//at least 1 point so to see __ on the bottom for zero (or missing) entries (as used on other apps)
return max(1, height)
}

I found a solution by wrapping the Int into an Unique:Identifiable struct. However, I don't really think this is the most beautiful solution. There most be a better way?
struct Unique: Identifiable {
let value: Int
let id = UUID()
}
#ViewBuilder
func stackFor(bar: Bar, scale: CGFloat) -> some View {
//sample data
let values = [4,4,5,6]
var index = 0
//generate a uuid for the whole array of values
let uniques = values.map({Unique(value: $0)})
ForEach(uniques) { unique in
Rectangle()
.fill(colorForStackedBar(index: index))
.frame(height: heightFor(value: unique.value, scale: scale) )
Rectangle()
.fill(Color.clear)
.frame(height: 1 )
//the let _ = ... is just a "hack" for being able to update the index in #ViewBuilder
let _ = index = index + 1
}
}

Here array has duplicates but also works!
import SwiftUI
struct ContentView: View {
var body: some View {
stackFor(scale: 1.0)
}
}
func stackFor(scale: CGFloat) -> some View {
let values: [Int] = [4 ,4 ,4 ,4 ,4 ,20, 20, 20, 50, 50, 50]
return VStack(spacing: 0.0) {
ForEach(values, id: \.self) { value in
Rectangle()
.fill(colorForStackedBar(index: value))
.frame(height: heightFor(value: value, scale: scale) )
Rectangle()
.frame(height: 5.0)
}
}
}
func colorForStackedBar(index: Int) -> Color {
return Color.red
}
func heightFor(value: Int, scale: CGFloat) -> CGFloat {
return max(1.0, CGFloat(value)*scale)
}

You can make the Unique struct conform to ExpressibleByIntegerLiteral:
struct Unique: Identifiable, ExpressibleByIntegerLiteral {
let value: Int
let id = UUID()
init(integerLiteral value: Int) {
self.value = value
}
}
which makes it easier to create an array of Unique:
let uniques: [Unique] = [4,4,5,6]

Related

SwiftUI ScrollView doesn't compute custom StaggeredGrid height

I am trying to create a new grid structure in SwiftUI to show cards with variable heights. To that extent, I use several LazyVStacks in a HStack and have a condition to display my data items in the correct order. This view works alone, adapts the number of columns to the size of the screen, but when using it in a ScrollView, its size is not computed properly and the following views end up beneath the grid instead of below it. Here is the code I used for the grid :
struct StaggeredGrid<Element, Content>: View where Content: View {
var preferredItemWidth: CGFloat
var data: [Element] = []
var content: (Element) -> Content
init(preferredItemWidth: CGFloat, data: [Element], #ViewBuilder content: #escaping (Element) -> Content) {
self.preferredItemWidth = preferredItemWidth
self.data = data
self.content = content
}
var body: some View {
GeometryReader { geometry in
HStack(alignment: .top, spacing: 20) {
ForEach(1...Int(geometry.size.width / preferredItemWidth), id: \.self) { number in
LazyVStack(spacing: 20) {
ForEach(0..<data.count, id: \.self) { index in
if (index+1) % Int(geometry.size.width / preferredItemWidth) == number % Int(geometry.size.width / preferredItemWidth) {
content(data[index])
}
}
}
}
}
.padding([.horizontal])
}
}
}
And the preview to show the behavior :
struct StaggeredGrid_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
StaggeredGrid(preferredItemWidth: 160, data: (1...10).map{"Item \($0)"}) { item in
Text(item)
.foregroundColor(.blue)
}
Text("I should be below the grid")
}
}
}
}
Here is a picture of the preview:
Wrong appearance in a ScrollView
And a picture when the ScrollView is commented out:
Expected behavior, ScrollView removed
Thank you in advance for any help or clue about this behavior I do not understand.
I quote #Asperi :
"ScrollView has no own size and GeometryReader has no own size, so you've got into chicken-egg problem, ie. no-one knows size to render, so collapsed. You must have definite frame size for items inside ScrollView."
Here you have to set the height of your StaggeredGrid, or the GeometryReader it contains.
In your case its height depends of course on the height of its content (i.e. the HStack). You can read this height (the height of its background / overlay for example) with a Reader. And use it to definite the frame size of your GeometryReader
For example :
struct StaggeredGrid<Element, Content>: View where Content: View {
var preferredItemWidth: CGFloat
var data: [Element] = []
var content: (Element) -> Content
init(preferredItemWidth: CGFloat, data: [Element], #ViewBuilder content: #escaping (Element) -> Content) {
self.preferredItemWidth = preferredItemWidth
self.data = data
self.content = content
}
#State private var gridHeight: CGFloat = 100
var body: some View {
GeometryReader { geometry in
HStack(alignment: .top, spacing: 20) {
ForEach(1...Int(geometry.size.width / preferredItemWidth), id: \.self) { number in
LazyVStack(spacing: 20) {
ForEach(0..<data.count, id: \.self) { index in
if (index+1) % Int(geometry.size.width / preferredItemWidth) == number % Int(geometry.size.width / preferredItemWidth) {
content(data[index])
}
}
}
}
}
.overlay(GeometryReader { proxy in
Color.clear.preference(
key: HeightPreferenceKey.self,
value: proxy.size.height
)
})
.onPreferenceChange(HeightPreferenceKey.self) {
gridHeight = $0
}
.padding([.horizontal])
}
.frame(height: gridHeight)
}
}
private struct HeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = nextValue()
}
}

Change array sequence in SwiftUI DragGesture

Im currently trying to build a deck of cards to represent the users cards.
This is my first prototype, please ignore the styling issues. I want the user to be able to change the sequence of the cards, which i tried to implement with a drag gesture.
My idea was that when the drag gesture's translation is bigger than the cards offset (or smaller than the cards negative offset) I just swap the two elements inside my array to swap the cards in the view (and viewModel because of the binding). Unfortunately that's not working and I cannot figure out why. This is my View:
struct CardHand: View {
#Binding var cards: [Card]
#State var activeCardIndex: Int?
#State var activeCardOffset: CGSize = CGSize.zero
var body: some View {
ZStack {
ForEach(cards.indices) { index in
GameCard(card: cards[index])
.offset(x: CGFloat(-30 * index), y: self.activeCardIndex == index ? -20 : 0)
.offset(activeCardIndex == index ? activeCardOffset : CGSize.zero)
.rotationEffect(Angle.degrees(Double(-2 * Double(index - cards.count))))
.zIndex(activeCardIndex == index ? 100 : Double(index))
.gesture(getDragGesture(for: index))
}
// Move the stack back to the center of the screen
.offset(x: CGFloat(12 * cards.count), y: 0)
}
}
private func getDragGesture(for index: Int) -> some Gesture {
DragGesture()
.onChanged { gesture in
self.activeCardIndex = index
self.activeCardOffset = gesture.translation
}
.onEnded { gesture in
if gesture.translation.width > 30, index < cards.count {
cards.swapAt(index, index + 1)
}
self.activeCardIndex = nil
// DEBUG: Try removing the card after drag gesture ended. Not working either.
cards.remove(at: index)
}
}
}
The GameCard:
struct GameCard: View {
let card: Card
var symbol: (String, Color) {
switch card.suit.name {
case "diamonds":
return ("♦", .red)
case "hearts":
return ("♥", .red)
case "spades":
return ("♠", .black)
case "clubs":
return ("♣", .black)
default:
return ("none", .black)
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white)
.addBorder(Color.black, width: 3, cornerRadius: 25)
VStack {
Text(self.card.rank.type.description())
Text(self.symbol.0)
.font(.system(size: 100))
.foregroundColor(self.symbol.1)
}
}
.frame(minWidth: 20, idealWidth: 80, maxWidth: 80, minHeight: 100, idealHeight: 100, maxHeight: 100, alignment: .center)
}
}
The Model behind it:
public struct Card: Identifiable, Hashable {
public var id: UUID = UUID()
public var suit: Suit
public var rank: Rank
}
public enum Type: Identifiable, Hashable {
case face(String)
case numeric(rankNumber: Int)
public var id: Type { self }
public func description() -> String{
switch self {
case .face(let description):
return description
case .numeric(let rankNumber):
return String(rankNumber)
}
}
}
public struct Rank: RankProtocol, Identifiable, Hashable {
public var id: UUID = UUID()
public var type: Type
public var value: Int
public var ranking: Int
}
public struct Suit: SuitProtocol, Identifiable, Hashable {
public var id: UUID = UUID()
public var name: String
public var value: Int
public init(name: String, value: Int) {
self.name = name
self.value = value
}
}
public protocol RankProtocol {
var type: Type { get }
var value: Int { get }
}
public protocol SuitProtocol {
var name: String { get }
}
Can anyone tell me why this is not working as I expected? Did I miss anything basic?
Thank you!
Lots of little bugs going on here. Here's a (rough) solution, which I'll detail below:
struct CardHand: View {
#Binding var cards: [Card]
#State var activeCardIndex: Int?
#State var activeCardOffset: CGSize = CGSize.zero
func offsetForCardIndex(index: Int) -> CGSize {
var initialOffset = CGSize(width: CGFloat(-30 * index), height: 0)
guard index == activeCardIndex else {
return initialOffset
}
initialOffset.width += activeCardOffset.width
initialOffset.height += activeCardOffset.height
return initialOffset
}
var body: some View {
ZStack {
ForEach(cards.indices) { index in
GameCard(card: cards[index])
.offset(offsetForCardIndex(index: index))
.rotationEffect(Angle.degrees(Double(-2 * Double(index - cards.count))))
.zIndex(activeCardIndex == index ? 100 : Double(index))
.gesture(getDragGesture(for: index))
}
// Move the stack back to the center of the screen
.offset(x: CGFloat(12 * cards.count), y: 0)
}
}
private func getDragGesture(for index: Int) -> some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { gesture in
self.activeCardIndex = index
self.activeCardOffset = gesture.translation
}
.onEnded { gesture in
if gesture.translation.width > 30 {
if index - 1 > 0 {
cards.swapAt(index, index - 1)
}
}
if gesture.translation.width < -30 {
cards.swapAt(index, index + 1)
}
self.activeCardIndex = nil
}
}
}
Trying to do two offset() calls in a row is problematic. I combined the two into offsetForCardIndex
Because you're doing a ZStack, remember that the first item in the cards array will be at the bottom of the stack and towards the right of the screen. That affected the logic later
You'll want to make sure you check the bounds of the array in swapAt so that you don't end up trying to swap beyond the indexes (which was what was happening before)
My "solution" is still pretty rough -- there should be more checking in place to make sure that the array can't go out of bounds. Also, in your original and in mine, it would probably make more sense from a UX perspective to be able to swap more than 1 position. But, that's a bigger issue outside the scope of this question.

SwiftUI - Constructing a LazyVGrid with expandable views

I'm trying to construct a two-column grid of quadratic views from an array of colors where one view expands to the size of four small views when clicked.
Javier from swiftui-lab.com gave me kind of a breakthrough with the idea of adding Color.clear as a "fake" view inside the ForEach to trick the VGrid into making space for the expanded view. This works fine for the boxes on the left of the grid. The boxes on the right, however, give me a no ends of trouble because they expand to the right and don't cause the VGrid to reallign properly:
I've tried several things like swapping the colors in the array, rotating the whole grid when one of the views on the right is clicked, adding varying numbers of Color.clear views - nothing has done the trick so far.
Here's the current code:
struct ContentView: View {
#State private var selectedColor : UIColor? = nil
let colors : [UIColor] = [.red, .yellow, .green, .orange, .blue, .magenta, .purple, .black]
private let padding : CGFloat = 10
var body: some View {
GeometryReader { proxy in
ScrollView {
LazyVGrid(columns: [
GridItem(.fixed(proxy.size.width / 2 - 5), spacing: padding, alignment: .leading),
GridItem(.fixed(proxy.size.width / 2 - 5))
], spacing: padding) {
ForEach(0..<colors.count, id: \.self) { id in
if selectedColor == colors[id] && id % 2 != 0 {
Color.clear
}
RectangleView(proxy: proxy, colors: colors, id: id, selectedColor: selectedColor, padding: padding)
.onTapGesture {
withAnimation{
if selectedColor == colors[id] {
selectedColor = nil
} else {
selectedColor = colors[id]
}
}
}
if selectedColor == colors[id] {
Color.clear
Color.clear
Color.clear
}
}
}
}
}.padding(.all, 10)
}
}
RectangleView:
struct RectangleView: View {
var proxy: GeometryProxy
var colors : [UIColor]
var id: Int
var selectedColor : UIColor?
var padding : CGFloat
var body: some View {
Color(colors[id])
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: resolveOffset(for: id))
}
// Used to offset the boxes after the expanded one to compensate for missing padding
func resolveOffset(for id: Int) -> CGFloat {
guard let selectedColor = selectedColor, let selectedIndex = colors.firstIndex(of: selectedColor) else { return 0 }
if id > selectedIndex {
return -(padding * 2)
}
return 0
}
func calculateFrame(for id: Int) -> CGFloat {
selectedColor == colors[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
I would be really grateful if you could point me in the direction of what I'm doing wrong.
P.S. If you run the code, you'll notice that the last black box is also not behaving as expected. That's another issue that I've not been able to solve thus far.
After giving up on the LazyVGrid to do the job, I kind of "hacked" two simple VStacks to be contained in a ParallelStackView. It lacks the beautiful crossover animation a LazyVGrid has and can only be implemented for two columns, but gets the job done - kind of. This is obviously a far cry from an elegant solution but I needed a workaround, so for anyone working on the same issue, here's the code (implemented as generic over the type it contains):
struct ParallelStackView<T: Equatable, Content: View>: View {
let padding : CGFloat
let elements : [T]
#Binding var currentlySelectedItem : T?
let content : (T) -> Content
#State private var selectedElement : T? = nil
#State private var selectedSecondElement : T? = nil
var body: some View {
let (transformedFirstArray, transformedSecondArray) = transformArray(array: elements)
func resolveClearViewHeightForFirstArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedSecondArray[id+1] == selectedSecondElement || (transformedSecondArray[1] == selectedSecondElement && id == 0) ? proxy.size.width + padding : 0
}
func resolveClearViewHeightForSecondArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedFirstArray[id+1] == selectedElement || (transformedFirstArray[1] == selectedElement && id == 0) ? proxy.size.width + padding : 0
}
return GeometryReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedFirstArray.count, id: \.self) { id in
if transformedFirstArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForFirstArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedFirstArray, id: id, selectedElement: selectedElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedElement == transformedFirstArray[id] {
selectedElement = nil
currentlySelectedItem = nil
} else {
selectedSecondElement = nil
selectedElement = transformedFirstArray[id]
currentlySelectedItem = selectedElement
}
}
}
}
}
}
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedSecondArray.count, id: \.self) { id in
if transformedSecondArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForSecondArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedSecondArray, id: id, selectedElement: selectedSecondElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedSecondElement == transformedSecondArray[id] {
selectedSecondElement = nil
currentlySelectedItem = nil
} else {
selectedElement = nil
selectedSecondElement = transformedSecondArray[id]
currentlySelectedItem = selectedSecondElement
}
}
}.rotation3DEffect(.init(degrees: 180), axis: (x: 0, y: 1, z: 0))
}
}
}
// You need to rotate the second VStack for it to expand in the correct direction (left).
// As now all text would be displayed as mirrored, you have to reverse that rotation "locally"
// with a .rotation3DEffect modifier (see 4 lines above).
.rotate3D()
.offset(x: resolveOffset(for: proxy))
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .topTrailing)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}.padding(10)
}
func resolveOffset(for proxy: GeometryProxy) -> CGFloat {
selectedSecondElement == nil ? proxy.size.width / 2 - padding / 2 : proxy.size.width
}
// Transform the original array to alternately contain nil and real values
// for the Color.clear views. You could just as well use other "default" values
// but I thought nil was quite explicit and makes it easier to understand what
// is going on. Then you split the transformed array into two sub-arrays for
// the VStacks:
func transformArray<T: Equatable>(array: [T]) -> ([T?], [T?]) {
var arrayTransformed : [T?] = []
array.map { element -> (T?, T?) in
return (nil, element)
}.forEach {
arrayTransformed.append($0.0)
arrayTransformed.append($0.1)
}
arrayTransformed = arrayTransformed.reversed()
var firstTransformedArray : [T?] = []
var secondTransformedArray : [T?] = []
for i in 0...arrayTransformed.count / 2 {
guard let nilValue = arrayTransformed.popLast(), let element = arrayTransformed.popLast() else { break }
if i % 2 == 0 {
firstTransformedArray += [nilValue, element]
} else {
secondTransformedArray += [nilValue, element]
}
}
return (firstTransformedArray, secondTransformedArray)
}
struct RectangleView: View {
let proxy: GeometryProxy
let elements : [T?]
let id: Int
let selectedElement : T?
let padding : CGFloat
let content : (T) -> Content
var body: some View {
content(elements[id]!)
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
}
func calculateFrame(for id: Int) -> CGFloat {
selectedElement == elements[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
}
extension View {
func rotate3D() -> some View {
modifier(StackRotation())
}
}
struct StackRotation: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
let c = CATransform3DIdentity
return ProjectionTransform(CATransform3DRotate(c, .pi, 0, 1, 0))
}
}

How to render multiline text in SwiftUI List with correct height?

I would like to have a SwiftUI view that shows many lines of text, with the following requirements:
Works on both macOS and iOS.
Shows a large number of strings (each string is backed by a separate model object).
I can do arbitrary styling to the multiline text.
Each string of text can be of arbitrary length, possibly spanning multiple lines and paragraphs.
The maximum width of each string of text is fixed to the width of the container. Height is variable according to the actual length of text.
There is no scrolling for each individual text, only the list.
Links in the text must be tappable/clickable.
Text is read-only, does not have to be editable.
Feels like the most appropriate solution would be to have a List view, wrapping native UITextView/NSTextView.
Here’s what I have so far. It implements most of the requirements EXCEPT having the correct height for the rows.
//
// ListWithNativeTexts.swift
// SUIToy
//
// Created by Jaanus Kase on 03.05.2020.
// Copyright © 2020 Jaanus Kase. All rights reserved.
//
import SwiftUI
let number = 20
struct ListWithNativeTexts: View {
var body: some View {
List(texts(count: number), id: \.self) { text in
NativeTextView(string: text)
}
}
}
struct ListWithNativeTexts_Previews: PreviewProvider {
static var previews: some View {
ListWithNativeTexts()
}
}
func texts(count: Int) -> [String] {
return (1...count).map {
(1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
}
}
#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor
struct NativeTextView: UIViewRepresentable {
var string: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isScrollEnabled = false
textView.dataDetectorTypes = .link
textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.textContainer.lineFragmentPadding = 0
let attributed = attributedString(for: string)
textView.attributedText = attributed
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
}
}
#else
typealias NativeFont = NSFont
typealias NativeColor = NSColor
struct NativeTextView: NSViewRepresentable {
var string: String
func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView()
textView.isEditable = false
textView.isAutomaticLinkDetectionEnabled = true
textView.isAutomaticDataDetectionEnabled = true
textView.textContainer?.lineFragmentPadding = 0
textView.backgroundColor = NSColor.clear
textView.textStorage?.append(attributedString(for: string))
textView.isEditable = true
textView.checkTextInDocument(nil) // make links clickable
textView.isEditable = false
return textView
}
func updateNSView(_ textView: NSTextView, context: Context) {
}
}
#endif
func attributedString(for string: String) -> NSAttributedString {
let attributedString = NSMutableAttributedString(string: string)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let range = NSMakeRange(0, (string as NSString).length)
attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
attributedString.addAttribute(.foregroundColor, value: NativeColor.red, range: range)
attributedString.addAttribute(.backgroundColor, value: NativeColor.yellow, range: range)
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
return attributedString
}
Here’s what it outputs on iOS. macOS output is similar.
How do I get this solution to size the text views with correct heights?
One approach that I have tried, but not shown here, is to give the height “from outside in” - to specify the height on the list row itself with frame. I can calculate the height of an NSAttributedString when I know the width, which I can obtain with geoReader. This almost works, but is buggy, and does not feel right, so I’m not showing it here.
Sizing List rows doesn't work well with SwiftUI.
However, I have worked out how to display a scroll of native UITextViews in a stack, where each item is dynamically sized based on the height of its attributedText.
I have put 2 point spacing between each item and tested with 80 items
using your text generator.
Here are the first three screenshots of scroll, and another screenshot
showing the very end of the scroll.
Here is the full class with extensions for attributedText height and regular string size, as well.
import SwiftUI
let number = 80
struct ListWithNativeTexts: View {
let rows = texts(count:number)
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 2) {
ForEach(0..<self.rows.count, id: \.self) { i in
self.makeView(geometry, text: self.rows[i])
}
}
}
}
}
func makeView(_ geometry: GeometryProxy, text: String) -> some View {
print(geometry.size.width, geometry.size.height)
// for a regular string size (not attributed text)
// let textSize = text.size(width: geometry.size.width, font: UIFont.systemFont(ofSize: 17.0, weight: .regular), padding: UIEdgeInsets.init(top: 0, left: 0, bottom: 0, right: 0))
// print("textSize: \(textSize)")
// return NativeTextView(string: text).frame(width: geometry.size.width, height: textSize.height)
let attributed = attributedString(for: text)
let height = attributed.height(containerWidth: geometry.size.width)
print("height: \(height)")
return NativeTextView(string: text).frame(width: geometry.size.width, height: height)
}
}
struct ListWithNativeTexts_Previews: PreviewProvider {
static var previews: some View {
ListWithNativeTexts()
}
}
func texts(count: Int) -> [String] {
return (1...count).map {
(1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
}
}
#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor
struct NativeTextView: UIViewRepresentable {
var string: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isScrollEnabled = false
textView.dataDetectorTypes = .link
textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.textContainer.lineFragmentPadding = 0
let attributed = attributedString(for: string)
textView.attributedText = attributed
// for a regular string size (not attributed text)
// textView.font = UIFont.systemFont(ofSize: 17.0, weight: .regular)
// textView.text = string
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
}
}
#else
typealias NativeFont = NSFont
typealias NativeColor = NSColor
struct NativeTextView: NSViewRepresentable {
var string: String
func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView()
textView.isEditable = false
textView.isAutomaticLinkDetectionEnabled = true
textView.isAutomaticDataDetectionEnabled = true
textView.textContainer?.lineFragmentPadding = 0
textView.backgroundColor = NSColor.clear
textView.textStorage?.append(attributedString(for: string))
textView.isEditable = true
textView.checkTextInDocument(nil) // make links clickable
textView.isEditable = false
return textView
}
func updateNSView(_ textView: NSTextView, context: Context) {
}
}
#endif
func attributedString(for string: String) -> NSAttributedString {
let attributedString = NSMutableAttributedString(string: string)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let range = NSMakeRange(0, (string as NSString).length)
attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
attributedString.addAttribute(.foregroundColor, value: NativeColor.red, range: range)
attributedString.addAttribute(.backgroundColor, value: NativeColor.yellow, range: range)
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
return attributedString
}
extension String {
func size(width:CGFloat = 220.0, font: UIFont = UIFont.systemFont(ofSize: 17.0, weight: .regular), padding: UIEdgeInsets? = nil) -> CGSize {
let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
label.numberOfLines = 0
label.lineBreakMode = NSLineBreakMode.byWordWrapping
label.font = font
label.text = self
label.sizeToFit()
if let pad = padding{
// add padding
return CGSize(width: label.frame.width + pad.left + pad.right, height: label.frame.height + pad.top + pad.bottom)
} else {
return CGSize(width: label.frame.width, height: label.frame.height)
}
}
}
extension NSAttributedString {
func height(containerWidth: CGFloat) -> CGFloat {
let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
return ceil(rect.size.height)
}
func width(containerHeight: CGFloat) -> CGFloat {
let rect = self.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: containerHeight),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil)
return ceil(rect.size.width)
}
}
Jeaanus,
I am not sure I understand your question entirely, but there are a couple of environmental variables and Insets you can add to change on SwiftUI List views spacing... Here is an example of what I am talking about.
Note it is important you add them to the right view, the listRowInsets is on the ForEach, the environment is on the List view.
List {
ForEach((0 ..< self.selections.count), id: \.self) { column in
HStack(spacing:0) {
Spacer()
Text(self.selections[column].name)
.font(Fonts.avenirNextCondensedBold(size: 22))
Spacer()
}
}.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}.environment(\.defaultMinListRowHeight, 20)
.environment(\.defaultMinListHeaderHeight, 0)
.frame(width: UIScreen.main.bounds.size.width, height: 180.5, alignment: .center)
.offset(x: 0, y: -64)
Mark

SwiftUI/PreferenceKey: How to avoid moving rects when scrolling?

I'm working on a tab bar that is scrollable and that has a moving background for the selected tab.
The solution is based on PreferenceKeys; however, I have a problem to get the moving background stable in relation to the tabs. Currently, it moves when scrolling, which is not desired; instead, it shall be fixed in relation to the tab item and scroll with them.
Why is this the case, and how to avoid that? When removing the ScrollView, the background moves correctly to the selected tab item. The TabItemButton is just a Button with some special label.
struct TabBar: View {
#EnvironmentObject var service: IRScrollableTabView.Service
// We support up to 15 items.
#State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 15)
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: geo.size.width))
.animation(.easeInOut(duration: 0.3))
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = selectedRect.minX + selectedRect.size.width / 2 - width / 2
debugPrint(selectedOffset)
return selectedOffset
}
}
struct Setter: View {
let index: Int
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.clear)
.preference(key: IRPreferenceKey.self,
value: [IRData(viewIndex: self.index,
rect: geo.frame(in: .named("IRReference")))])
}
}
}
struct IRPreferenceKey: PreferenceKey {
typealias Value = [IRData]
static var defaultValue: [IRScrollableTabView.IRData] = []
static func reduce(value: inout [IRScrollableTabView.IRData], nextValue: () -> [IRScrollableTabView.IRData]) {
value.append(contentsOf: nextValue())
}
}
struct IRData: Equatable {
let viewIndex: Int
let rect: CGRect
}
The service is defined this way (i.e., nothing special...):
final class Service: ObservableObject {
#Published var currentDestinationView: AnyView
#Published var tabItems: [IRScrollableTabView.Item]
#Published var selectedIndex: Int { didSet { debugPrint("selectedIndex: \(selectedIndex)") } }
init(initialDestinationView: AnyView,
tabItems: [IRScrollableTabView.Item],
initialSelectedIndex: Int) {
self.currentDestinationView = initialDestinationView
self.tabItems = tabItems
self.selectedIndex = initialSelectedIndex
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var title: String
var image: Image = Image(systemName: "circle")
}
I solved the problem! The trick seemed to be to put another GeometryReader around the Indicator view and to take its width for calculating the offset. The .onPreferenceChange must be attached to the HStack, and the .coordinateSpace to the ZStack. Now it's working...
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
GeometryReader { innerGeo in
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: innerGeo.size.width))
.animation(.easeInOut(duration: 0.3))
}
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = -width / 2 + CGFloat(80 * self.service.selectedIndex) + selectedRect.size.width / 2
debugPrint(selectedOffset)
return selectedOffset
}