Placement of UIViewRepresentable within list cells in SwiftUI - ios

When adding a custom UILabel to a List in SwiftUI, I get errors with cell reuse, where the label on some cells isn't visible at all, and on some cells it is placed in top-left without any regard for the cell's padding. It always renders perfectly on the initial cells.
The problem doesn't occur when using a ScrollView. Is this a known bug, and are there good workarounds?
GeometryReader { geometry in
List {
ForEach(self.testdata, id: \.self) { text in
Group {
AttributedLabel(attributedText: NSAttributedString(string: text), maxWidth: geometry.size.width - 40)
}.padding(.vertical, 20)
}
}
}
struct AttributedLabel: UIViewRepresentable {
let attributedText: NSAttributedString
let maxWidth: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> UILabel {
let label = UILabel()
label.preferredMaxLayoutWidth = maxWidth
label.attributedText = attributedText
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.backgroundColor = UIColor.red
return label
}
func updateUIView(_ label: UILabel, context: UIViewRepresentableContext<Self>) {}
}

It is not related to ScrollView or SwiftUI bug.
I think You have a issue with your AttributedLabel Class. I tried using normal Text and it is working fine.
List {
ForEach(self.testdata, id: \.self) { text in
Group {
Text(student.name)
.background(Color.red)
}.padding(.vertical, 20)
}
}

There does seem to be a workaround for this.
The first step is to get the model to first return an empty array of items and later return the actual update. This will force the view to update. And then, after a short pause, it can be followed by the actual update. For this case, this isn't quite enough. Doing that alone still leads to the layout issues. Somehow the list (presumably backed by a UITableView that is aggressively recycling its cells) still manages to keep the state the is somehow causing the trouble. And so...
The second step is to get the view to offer something other than the List when there are no items. This is done using the SwiftUI if and else to use a different view depending on whether there are any items. With the changes to the model, as per step 1, this happens every update.
Doing steps (1) and (2) appears to workaround the issue. The sample code below also includes an .animation(.none) method on the View. This was necessary in my code, but in the example code below it doesn't seem to be needed.
A downside of this workaround is that you will lose animations. And clearly it is something of a hack that, if Apple make changes in the future, might not continue to work. (Still, maybe by then the bug will have been fixed.)
import SwiftUI
struct ContentView: View {
#ObservedObject var model = TestData()
var body: some View {
VStack() {
GeometryReader { geometry in
// handle the no items case by offering up a different view
// this appears to be necessary to workaround the issues
// where table cells are re-used and the layout goes wrong
// Don't show the "No Data" message unless there really is no data,
// i.e. skip case where we're just delaying to workaround the issue.
if self.model.sampleList.isEmpty {
Text("No Data")
.foregroundColor(self.model.isModelUpdating ? Color.clear : Color.secondary)
.frame(width: geometry.size.width, height: geometry.size.height) // centre the text
}
else {
List(self.model.sampleList, id:\.self) { attributedString in
AttributedLabel(attributedText: attributedString, maxWidth: geometry.size.width - 40)
}
}
}.animation(.none) // this MAY not be necessary for all cases
Spacer()
Button(action: { self.model.shuffle()} ) { Text("Shuffle") }.padding(20)
}
}
}
struct AttributedLabel: UIViewRepresentable {
let attributedText: NSAttributedString
let maxWidth: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> UILabel {
let label = UILabel()
label.preferredMaxLayoutWidth = maxWidth
label.attributedText = attributedText
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.backgroundColor = UIColor.red
return label
}
func updateUIView(_ label: UILabel, context: UIViewRepresentableContext<Self>) {
// function required by protoocol - NO OP
}
}
class TestData : ObservableObject {
#Published var sampleList = [NSAttributedString]()
#Published var isModelUpdating = false
private var allSamples = [NSAttributedString]()
func shuffle() {
let filtered = allSamples.filter{ _ in Bool.random() }
let shuffled = filtered.shuffled()
// empty the sampleList - this will trigger the View that is
// observing the model to update and handle the no items case
self.sampleList = [NSAttributedString]()
self.isModelUpdating = true
// after a short delay update the sampleList - this will trigger
// the view that is observing the model to update
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
self.sampleList = shuffled
self.isModelUpdating = false
}
}
init() {
generateSamples()
shuffle()
}
func generateSamples() {
DispatchQueue.main.async {
var samples = [NSAttributedString]()
samples.append("The <em>quick</em> brown fox <strong>boldly</strong> jumped over the <em>lazy</em> dog.".fromHTML)
samples.append("<h1>SwiftUI</h1><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p><p>At the time of writing, still very much a <em>work in progress</em>. Normal and <em>italic</em>. And <strong>strong</strong> too.</p>".fromHTML)
samples.append("<h1>Test Cells</h1><p>Include cells that have different heights to demonstrate what is going on. Make some of them really quite long. If they are all showing the list is going to need to scroll at least on smaller devices.</p><p>Include cells that have different heights to demonstrate what is going on. Make some of them really quite long. If they are all showing the list is going to need to scroll at least on smaller devices.</p><p>Include cells that have different heights to demonstrate what is going on. Make some of them really quite long. If they are all showing the list is going to need to scroll at least on smaller devices.</p> ".fromHTML)
samples.append("<h3>List of the day</h3><p>And he said:<ul><li>Expect the unexpected</li><li>The sheep is not a creature of the air</li><li>Chance favours the prepared observer</li></ul>And now, maybe, some commentary on that quote.".fromHTML)
samples.append("Something that is quite short but that is more than just one line long on a phone maybe. This might do it.".fromHTML)
self.allSamples = samples
}
}
}
extension String {
var fromHTML : NSAttributedString {
do {
return try NSAttributedString(data: Data(self.utf8), options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
], documentAttributes: nil)
}
catch {
return NSAttributedString(string: self)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

I had the similar problem and solved it by adding frame to UIViewRepresentable with getTextFrame(text),
GeometryReader { geometry in
List {
ForEach(self.testdata, id: \.self) { text in
Group {
AttributedLabel(attributedText: NSAttributedString(string: text), maxWidth: geometry.size.width - 40)
// add this frame
.frame(width: getTextFrame(text).width height: getTextFrame(text).height)
}.padding(.vertical, 20)
}
}
}
func getTextFrame(for text: String, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> CGSize {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .body)
]
let attributedText = NSAttributedString(string: text, attributes: attributes)
let width = maxWidth != nil ? min(maxWidth!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let height = maxHeight != nil ? min(maxHeight!, CGFloat.greatestFiniteMagnitude) : CGFloat.greatestFiniteMagnitude
let constraintBox = CGSize(width: width, height: height)
let rect = attributedText.boundingRect(with: constraintBox, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).integral
return rect.size
}

Related

How to "unpack" the content of a ForEach

I am building a #resultBuilder in my app. This resultBuilder takes Views and returns them in an array. This is necessary because the view which takes the content (WrappingHStack) needs it as an array. My current source looks like this:
#resultBuilder public struct ViewArrayBuilder {
public static func buildBlock() -> [EmptyView] {
[EmptyView()]
}
public static func buildBlock<Content: View>(_ content: Content) -> [AnyView] {
[AnyView(content)]
}
}
struct WrappingHStack: View {
#usableFromInline var alignment: Alignment = .topLeading
#usableFromInline var spacing: CGFloat = 8.0
#usableFromInline var maxRows: Int = Int.max
#usableFromInline let content: [AnyView]
#State private var height: CGFloat = 0
#usableFromInline init(alignment: Alignment = .topLeading,
spacing: CGFloat = 8.0,
maxRows: Int = Int.max,
content: [AnyView]) {
self.alignment = alignment
self.spacing = spacing
self.maxRows = maxRows
self.content = content
}
#inlinable public init(alignment: Alignment = .topLeading,
spacing: CGFloat = 8.0,
maxRows: Int = Int.max,
#ViewArrayBuilder content: () -> [AnyView]) {
self.init(alignment: alignment, spacing: spacing, maxRows: maxRows, content: content())
}
}
This all works fine if used like so:
WrappingHStack(maxRows: 2) {
Text("1")
Text("2")
...
}
If used with a ForEach in the closure it is recognised as one view and an array with the single ForEach is returned. But I want to get the contents of the ForEach and put them in the array. I was thinking about checking the type of the content and if a ForEach is detected it would be "unpacked".
public static func buildBlock<Content: View>(_ content: Content) -> [AnyView] {
// Doesn’t work because of this error message:
// Protocol 'RandomAccessCollection' as a type cannot conform to the protocol itself
if let forEachContent = content as? ForEach<RandomAccessCollection, Any, Any> {
return content.data.map({ elem in AnyView(content.content(elem)) })
}
return [AnyView(content)]
}
But I can’t seem to find a way to correctly ask for the ForEach type.
How would this be done? Or are there better ways to "unpack" the content of the ForEach?
Update
Why I need that?
I try to create a "wrapping" HStack. That is a view that lays out its children horizontally like a normal HStack. Once the available width is used up it wraps the children and continues on the next line. I base my approach on this article.
So at one point in time I do need the views created with the ForEach construct to lay them out as I want to. If I were able to correctly cast the content parameter of the buildBlock method to the ForEach type I could use the content function of ForEach to create the views. Like shown in the code block above.
I’m also open for other suggestions which accomplish the wrapping stack I need. (The lazy grids Apple provides are not what I want. My child views are of different width and I want them to flow like text would within the WrappingHStack.)
WrappingHStack library does the wrapping and can be used as a forEach:
WrappingHStack(1...30, id:\.self) {
Text("Item: \($0)")
}

How do I scale down a navigationTitle in SwiftUI?

I have the same problem as described in navigationtitle too long in swiftui -- I want to scale down my navigation bar title to fit the available space. Here's the problem:
Here's my code, which incorporates the suggestion made in the answer to the other referenced question:
struct AboutView: View {
#Binding var showAboutView: Bool
var body: some View {
NavigationView {
Form {
Section() {
Text("A placeholder for information about this app.\n\nDetails forthcoming....")
}
if let url = URL(string: "mailto:sarjo#sarjosoftware.com?subject=My+Wonderful+App") {
Section() {
Link("Contact the Author", destination: url)
}
}
}
// *** suggested solution ***
.navigationTitle(Text("About My Wonderful App").minimumScaleFactor(0.5).lineLimit(1))
.navigationBarItems(trailing: Button(action: {
showAboutView = false
}) {
Text("Done").bold()
})
}
}
}
But this code fails to build with the error:
Instance method 'navigationTitle' requires that 'some View' conform to 'StringProtocol' on the line Form {.
If I move the modifiers on the title text outside to apply to the navigationTitle modifier itself, as shown here:
.navigationTitle(Text("About My Wonderful App")).minimumScaleFactor(0.5).lineLimit(1)
this code builds and runs, but the modifiers are not applied to the title text but to the body text beginning "A placeholder for :
Thanks for any suggestions.
You can try .toolbar especially because .navigationBarItems is deprecated
struct AboutView: View {
#Binding var showAboutView: Bool
var body: some View {
NavigationView {
Form {
Section() {
Text("A placeholder for information about this app.\n\nDetails forthcoming....")
}
if let url = URL(string: "mailto:sarjo#sarjosoftware.com?subject=My+Wonderful+App") {
Section() {
Link("Contact the Author", destination: url)
}
}
}
.toolbar(content: {
ToolbarItem(placement: .principal, content: {
Text("About My Wonderful App")
.font(.title2).fontWeight(.bold)
})
ToolbarItem(placement: .navigationBarTrailing, content: {
Button(action: {showAboutView = false}) {
Text("Done").bold()
}
})
})
}
}
}
I read your problem.
my suggestion is to reduce the font size of NavigationTitle
you can reduce your NavigationTitle by this Code :
init() {
// this is not the same as manipulating the proxy directly
let appearance = UINavigationBarAppearance()
// this overrides everything you have set up earlier.
appearance.configureWithTransparentBackground()
// this only applies to big titles
appearance.largeTitleTextAttributes = [
.font : UIFont.systemFont(ofSize: 30),
NSAttributedString.Key.foregroundColor : UIColor.black
]
// this only applies to small titles
appearance.titleTextAttributes = [
.font : UIFont.systemFont(ofSize: 20),
NSAttributedString.Key.foregroundColor : UIColor.black
]
//In the following two lines you make sure that you apply the style for good
UINavigationBar.appearance().scrollEdgeAppearance = appearance
UINavigationBar.appearance().standardAppearance = appearance
// This property is not present on the UINavigationBarAppearance
// object for some reason and you have to leave it til the end
UINavigationBar.appearance().tintColor = .black
}
you should add this code upper the body.
also, I attached two links for helping you out of NavigationTitle.
I hope these are useful for you.
Apple
hackingwithswift
Tested on SwiftUI's NavigationView and NavigationStack.
This answer is derived from Amirreza Jolani's answer which is also similar to the code provided from "Customizing SwiftUI Navigation Bar". I thought some additional explanation might help.
The following function formats the singleton UINavigationBar and takes in the title and large titles' desired font sizes:
func formatNavTitle(_ fontSize: CGFloat, _ largeFontSize: CGFloat) {
let appearance = UINavigationBarAppearance()
appearance.largeTitleTextAttributes = [
.font : UIFont.systemFont(ofSize: largeFontSize),
NSAttributedString.Key.foregroundColor : UIColor.label
]
appearance.titleTextAttributes = [
.font : UIFont.systemFont(ofSize: fontSize),
NSAttributedString.Key.foregroundColor : UIColor.label
]
UINavigationBar.appearance().scrollEdgeAppearance = appearance
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().tintColor = .label
}
Usage: formatNavTitle(20, 30)
Features that shrink text such as minimumScaleFactor and tightening are limited to UILabel and SwiftUI's Text. Unfortunately those features don't appear to be available with UINavigationBar which uses NSAttributedString. And SwiftUI's navigation title appears to be part of a wrapper around UINavigationBar.
But the font size can be adjusted for the regular title and the large title using the function above. After adding the function to the app, an init statement in this example was also added to the main entrypoint of the app although could be added to AboutView instead:
#main
struct myExampleApp: App {
init() { formatNavTitle(20, 30) }
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
This results in the large title being 30 points and the regular title being 20 points:

SwiftUI - How to add attributed accessibility label for VoiceOver, to control spoken pitch?

I want to have VoiceOver speak a View's accessibility label in multiple pitches. For example, for "Raised string. Normal string.", I want "Raised string" to have a 1.5 pitch, and "Normal string." to have the default 1.0 pitch.
With UIKit, I can set the element's accessibilityAttributedLabel with a NSAttributedString and NSAttributedString.Key.accessibilitySpeechPitch. Something like this:
let pitchAttribute = [NSAttributedString.Key.accessibilitySpeechPitch: 1.5]
let string = NSMutableAttributedString()
let raisedString = NSAttributedString(string: "Raised string.", attributes: pitchAttribute)
string.append(raisedString)
let normalString = NSAttributedString(string: "Normal string.")
string.append(normalString)
squareView.isAccessibilityElement = true
squareView.accessibilityAttributedLabel = string
The result, which is exactly I want (Audio link):
However, with SwiftUI, there only seems to be a .accessibility(label: Text) modifier. This is my code:
struct ContentView: View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.accessibility(label: Text("Raised string. Normal string."))
}
}
And this is the result (Audio link):
As you can hear, "Raised string." and "Normal string." are spoken in the same pitch. This is as expected, because I passed in a solitary Text for the label.
But is there any way I can set the spoken pitch in SwiftUI? I can't find a way to set just one pitch, never mind two.
Love to see developers working to include accessibility!
In iOS 15, you can have your function return an AttributedString and then set the view's accessibility label with that AttributedString from your function:
.accessibility(label: Text(getAccessibilityAttributedLabel()))
A sample function with one pitch value:
func getAccessibilityAttributedLabel() -> AttributedString {
var pitchSpeech = AttributedString("Raised pitch")
pitchSpeech.accessibilitySpeechAdjustedPitch = 1.5
return pitchSpeech
}
Well, I guess it's UIViewRepresentable time (Yay 😔). Unless someone has a better answer, this is what I came up with:
struct ContentView: View {
var body: some View {
RectangleView(
accessibilityAttributedLabel: getAccessibilityAttributedLabel(),
fill: UIColor.blue /// pass color into initializer
)
.frame(width: 100, height: 100)
}
/// make the attributed string
func getAccessibilityAttributedLabel() -> NSAttributedString {
let pitchAttribute = [NSAttributedString.Key.accessibilitySpeechPitch: 1.5]
let string = NSMutableAttributedString()
let raisedString = NSAttributedString(string: "Raised string.", attributes: pitchAttribute)
string.append(raisedString)
let normalString = NSAttributedString(string: "Normal string.")
string.append(normalString)
return string
}
}
struct RectangleView: UIViewRepresentable {
var accessibilityAttributedLabel: NSAttributedString
var fill: UIColor
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView {
let uiView = UIView()
uiView.backgroundColor = fill
uiView.isAccessibilityElement = true
uiView.accessibilityAttributedLabel = accessibilityAttributedLabel /// set the attributed label here
return uiView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {}
}

Infinite vertical scrollview both ways (add items dynamically at top/bottom) that doesn’t interfere with scroll position when you add to list start

I’m after a vertical scrollview that’s infinite both ways: scrolling up to the top or down to the bottom results in more items being added dynamically. Almost all help I’ve encountered is only concerned with the bottom side being infinite in scope. I did come across this relevant answer but it’s not what I’m specifically looking for (it’s adding items automatically based on time duration, and requires interaction with direction buttons to specify which way to scroll). This less relevant answer however has been quite helpful. Based on the suggestion made there, I realised I can keep a record of items visible at any time, and if they happen to be X positions from the top/bottom, to insert an item at the starting/ending index on the list.
One other note is I’m getting the list to start in the middle, so there’s no need to add anything either way unless you’ve moved 50% up/down.
To be clear, this is for a calendar screen that I want the user to be scroll to any time freely.
struct TestInfinityList: View {
#State var visibleItems: Set<Int> = []
#State var items: [Int] = Array(0...20)
var body: some View {
ScrollViewReader { value in
List(items, id: \.self) { item in
VStack {
Text("Item \(item)")
}.id(item)
.onAppear {
self.visibleItems.insert(item)
/// if this is the second item on the list, then time to add with a short delay
/// another item at the top
if items[1] == item {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
withAnimation(.easeIn) {
items.insert(items.first! - 1, at: 0)
}
}
}
}
.onDisappear {
self.visibleItems.remove(item)
}
.frame(height: 300)
}
.onAppear {
value.scrollTo(10, anchor: .top)
}
}
}
}
This is mostly working fine except for a small but important detail. When an item is added from the top, depending on how I’m scrolling down, it can sometimes be jumpy. This is most noticeable towards the end of clip attached.
I tried your code and couldn't fix anything with List OR ScrollView, but it is possible to as a uiscrollview that scrolls infinitly.
1.wrap that uiscrollView in UIViewRepresentable
struct ScrollViewWrapper: UIViewRepresentable {
private let uiScrollView: UIInfiniteScrollView
init<Content: View>(content: Content) {
uiScrollView = UIInfiniteScrollView()
}
init<Content: View>(#ViewBuilder content: () -> Content) {
self.init(content: content())
}
func makeUIView(context: Context) -> UIScrollView {
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
}
}
2.this is my whole code for the infinitly scrolling uiscrollview
class UIInfiniteScrollView: UIScrollView {
private enum Placement {
case top
case bottom
}
var months: [Date] {
return Calendar.current.generateDates(inside: Calendar.current.dateInterval(of: .year, for: Date())!, matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0))
}
var visibleViews: [UIView] = []
var container: UIView! = nil
var visibleDates: [Date] = [Date()]
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: (*) otherwise can cause a bug of infinite scroll
func setup() {
contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 6)
scrollsToTop = false // (*)
showsVerticalScrollIndicator = false
container = UIView(frame: CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height))
container.backgroundColor = .purple
addSubview(container)
}
override func layoutSubviews() {
super.layoutSubviews()
recenterIfNecessary()
placeViews(min: bounds.minY, max: bounds.maxY)
}
func recenterIfNecessary() {
let currentOffset = contentOffset
let contentHeight = contentSize.height
let centerOffsetY = (contentHeight - bounds.size.height) / 2.0
let distanceFromCenter = abs(contentOffset.y - centerOffsetY)
if distanceFromCenter > contentHeight / 3.0 {
contentOffset = CGPoint(x: currentOffset.x, y: centerOffsetY)
visibleViews.forEach { v in
v.center = CGPoint(x: v.center.x, y: v.center.y + (centerOffsetY - currentOffset.y))
}
}
}
func placeViews(min: CGFloat, max: CGFloat) {
// first run
if visibleViews.count == 0 {
_ = place(on: .bottom, edge: min)
}
// place on top
var topEdge: CGFloat = visibleViews.first!.frame.minY
while topEdge > min {topEdge = place(on: .top, edge: topEdge)}
// place on bottom
var bottomEdge: CGFloat = visibleViews.last!.frame.maxY
while bottomEdge < max {bottomEdge = place(on: .bottom, edge: bottomEdge)}
// remove invisible items
var last = visibleViews.last
while (last?.frame.minY ?? max) > max {
last?.removeFromSuperview()
visibleViews.removeLast()
visibleDates.removeLast()
last = visibleViews.last
}
var first = visibleViews.first
while (first?.frame.maxY ?? min) < min {
first?.removeFromSuperview()
visibleViews.removeFirst()
visibleDates.removeFirst()
first = visibleViews.first
}
}
//MARK: returns the new edge either biggest or smallest
private func place(on: Placement, edge: CGFloat) -> CGFloat {
switch on {
case .top:
let newDate = Calendar.current.date(byAdding: .month, value: -1, to: visibleDates.first ?? Date())!
let newMonth = makeUIViewMonth(newDate)
visibleViews.insert(newMonth, at: 0)
visibleDates.insert(newDate, at: 0)
container.addSubview(newMonth)
newMonth.frame.origin.y = edge - newMonth.frame.size.height
return newMonth.frame.minY
case .bottom:
let newDate = Calendar.current.date(byAdding: .month, value: 1, to: visibleDates.last ?? Date())!
let newMonth = makeUIViewMonth(newDate)
visibleViews.append(newMonth)
visibleDates.append(newDate)
container.addSubview(newMonth)
newMonth.frame.origin.y = edge
return newMonth.frame.maxY
}
}
func makeUIViewMonth(_ date: Date) -> UIView {
let month = makeSwiftUIMonth(from: date)
let hosting = UIHostingController(rootView: month)
hosting.view.bounds.size = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 0.55)
hosting.view.clipsToBounds = true
hosting.view.center.x = container.center.x
return hosting.view
}
func makeSwiftUIMonth(from date: Date) -> some View {
return MonthView(month: date) { day in
Text(String(Calendar.current.component(.day, from: day)))
}
}
}
watch that one closely, its pretty much self explanatory, taken from WWDC 2011 idea, you reset the offset to midle of screen when you get close enough to the edge, and it all comes down to tiling your views so they all appear one on top of each other. if you want any clarification for that class please ask in comments.
when you have those 2 figured out, then you glue the SwiftUIView which is also in the class provided. for now the only way for the views to be seen on screen is to specify an explict size for hosting.view, if you figure out how to make the SwiftUIView size the hosting.view, please tell me in the comments, i am looking for an answer for that. hope that code helps someone, if something is wrong please leave a comment.
After poking at your code I believe that this jumpiness that you're seeing is caused by this:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
withAnimation(.easeIn) {
items.insert(items.first! - 1, at: 0)
}
}
If you remove both and only leave items.insert(items.first! - 1, at: 0) the jumpiness will stop.
I've been banging my head against the wall with this problem for the past two days... Taking away the DispatchQueue like #Ferologics suggested almost works, but you run into a potential problem of an infinite auto-scroll if you pull down too hard. I ended up scrapping the infinite scroller, and using a pulldown-refresh SwiftUIRefresh to load new items from the top. It does the job for now, but I still would love to know how to get true infinite scrolling going up!
import SwiftUI
import SwiftUIRefresh
struct InfiniteChatView: View {
#ObservedObject var viewModel = InfiniteChatViewModel()
var body: some View {
VStack {
Text("Infinite Scroll View Testing...")
Divider()
ScrollViewReader { proxy in
List(viewModel.stagedChats, id: \.id) { chat in
Text(chat.text)
.padding()
.id(chat.id)
.transition(.move(edge: .top))
}
.pullToRefresh(isShowing: $viewModel.chatLoaderShowing, onRefresh: {
withAnimation {
viewModel.insertPriors()
}
viewModel.chatLoaderShowing = false
})
.onAppear {
proxy.scrollTo(viewModel.stagedChats.last!.id, anchor: .bottom)
}
}
}
}
}
And the ViewModel:
class InfiniteChatViewModel: ObservableObject {
#Published var stagedChats = [Chat]()
#Published var chatLoaderShowing = false
var chatRepo: [Chat]
init() {
self.chatRepo = Array(0...1000).map { Chat($0) }
self.stagedChats = Array(chatRepo[500...520])
}
func insertPriors() {
guard let id = stagedChats.first?.id else {
print("first member of stagedChats does not exist")
return
}
guard let firstIndex = self.chatRepo.firstIndex(where: {$0.id == id}) else {
print(chatRepo.count)
print("ID \(id) not found in chatRepo")
return
}
stagedChats.insert(contentsOf: chatRepo[firstIndex-5...firstIndex-1], at: 0)
}
}
struct Chat: Identifiable {
var id: String = UUID().uuidString
var text: String
init(_ number: Int) {
text = "Chat \(number)"
}
}

Size of UIViewRepresentable in SwiftUI [duplicate]

Setting lineBreakMode to byWordWrapping and set numberOfLines to 0 does not seem to be sufficient:
struct MyTextView: UIViewRepresentable {
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.text = "Here's a lot of text for you to display. It won't fit on the screen."
return label
}
func updateUIView(_ view: UILabel, context: Context) {
}
}
struct MyTextView_Previews: PreviewProvider {
static var previews: some View {
MyTextView()
.previewLayout(.fixed(width: 300, height: 200))
}
}
The text does not wrap, regardless of which setting I use for lineBreakMode. The canvas preview and live preview both look like this:
The closest I've gotten is setting preferredMaxLayoutWidth, which does cause the text to wrap, but there doesn't seem to be a value that means "whatever size the View is".
Possible solution is to declare the width as a variable on MyTextView:
struct MyTextView: UIViewRepresentable {
var width: CGFloat
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.preferredMaxLayoutWidth = width
label.text = "Here's a lot of text for you to display. It won't fit on the screen."
return label
}
func updateUIView(_ view: UILabel, context: Context) {
}
}
and then use GeometryReader to findout how much space there is avaible and pass it into the intializer:
struct ExampleView: View {
var body: some View {
GeometryReader { geometry in
MyTextView(width: geometry.size.width)
}
}
}
Try to use this magic line in makeUIView() func
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
I found a somehow "nasty" approach that allows a UILabel to properly wrap when used as a UIViewRepresentable (even when inside a ScrollView), without the need for GeometryReader:
Whenever creating your UILabel:
label.setContentCompressionResistancePriority(.defaultLow,
for: .horizontal)
label.setContentHuggingPriority(.defaultHigh,
for: .vertical)
This ensures that:
the label will break line and not have an infinite width
the label will not add grow unnecessarily in height, which may happen in some circumstances.
Then...
Add a width property to your UIViewRepresentable that will be used to set the preferredMaxLayoutWidth
Use your UIViewRepresentable into a vanilla SwiftUI.View
Add a GeometryReader as an overlay to prevent expansion
Trigger the measurement after a soft delay, modifying some state to trigger a new pass.
i.e.:
public var body: some View {
MyRepresentable(width: $width,
separator: separator,
content: fragments)
.overlay(geometryOverlay)
.onAppear { shouldReadGeometry = true }
}
// MARK: - Private Props
#State private var width: CGFloat?
#State private var shouldReadGeometry = false
#ViewBuilder
var geometryOverlay: some View {
if shouldReadGeometry {
GeometryReader { g in
SwiftUI.Color.clear.onAppear {
self.width = g.size.width
}
}
}
}
OLD ANSWER:
...
In your updateUIView(_:context:):
if let sv = uiView.superview, sv.bounds.width != 0 {
let shouldReloadState = uiView.preferredMaxLayoutWidth != sv.bounds.width
uiView.preferredMaxLayoutWidth = sv.bounds.width
if shouldReloadState {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
self.stateToggle.toggle() // a Bool #State you can add in your struct
}
}
}
Disclaimer: I'm not a huge fan of main.async calls, particularly when they come in combination with some arbitrary delay, but this seems to get the work done in a consistent way.

Resources