Size of UIViewRepresentable in SwiftUI [duplicate] - ios

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.

Related

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)"
}
}

Placement of UIViewRepresentable within list cells in SwiftUI

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
}

Access underlying UITableView from SwiftUI List

Using a List view, is there a way to access (and therefore modify) the underlying UITableView object without reimplementing the entire List as a UIViewRepresentable?
I've tried initializing a List within my own UIViewRepresentable, but I can't seem to get SwiftUI to initialize the view when I need it to, and I just get an empty basic UIView with no subviews.
This question is to help find an answer for Bottom-first scrolling in SwiftUI.
Alternatively, a library or other project that reimplements UITableView in SwiftUI would also answer this question.
The answer is Yes. There's an amazing library that lets you inspect the underlying UIKit views. Here's a link to it.
The answer is no. As of iOS 13, SwiftUI's List is not currently designed to replace all the functionality and customizability of UITableView. It is designed to meet the most basic use of a UITableView: a standard looking, scrollable, editable list where you can place a relatively simply view in each cell.
In other words, you are giving up customizability for the simplicity of having swipes, navigation, moves, deletes, etc. automatically implemented for you.
I'm sure that as SwiftUI evolves, List (or an equivalent view) will get more customizable, and we'll be able to do things like scroll from the bottom, change padding, etc. The best way to make sure this happens is to file feedback suggestions with Apple. I'm sure the SwiftUI engineers are already hard at work designing the features that will appear at WWDC 2020. The more input they have to guide what the community wants and needs, the better.
I found a library called Rotoscope on GitHub (I am not the author of this).
This library is used to implement RefreshUI also on GitHub by the same author.
How it works is that Rotoscope has a tagging method, which overlays a 0 sized UIViewRepresentable on top of your List (so it's invisible). The view will dig through the chain of views and eventually find the UIHostingView that's hosting the SwiftUI views. Then, it will return the first subview of the hosting view, which should contains a wrapper of UITableView, then you can access the table view object by getting the subview of the wrapper.
The RefreshUI library uses this library to implement a refresh control to the SwiftUI List (you can go into the GitHub link and check out the source to see how it's implemented).
However, I see this more like a hack than an actual method, so it's up to you to decide whether you want to use this or not. There are no guarantee that it will continue working between major updates as Apple could change the internal view layout and this library will break.
You can Do it. But it requires a Hack.
Add Any custom UIView
Use UIResponder to backtrack until you find table View.
Modify UITableView The way you like.
Code Example of Adding Pull to refresh:
//1: create a custom view
final class UIKitView : UIViewRepresentable {
let callback: (UITableView) -> Void
init(leafViewCB: #escaping ((UITableView) -> Void)) {
callback = leafViewCB
}
func makeUIView(context: Context) -> UIView {
let view = UIView.init(frame: CGRect(x: CGFloat.leastNormalMagnitude,
y: CGFloat.leastNormalMagnitude,
width: CGFloat.leastNormalMagnitude,
height: CGFloat.leastNormalMagnitude))
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if let superView = uiView.superview {
superView.backgroundColor = uiView.backgroundColor
}
if let tableView = uiView.next(UITableView.self) {
callback(tableView)
}
}
}
extension UIResponder {
func next<T: UIResponder>(_ type: T.Type) -> T? {
return next as? T ?? next?.next(type)
}
}
////Use:
struct Result: Identifiable {
var id = UUID()
var value: String
}
class RefreshableObject: ObservableObject {
let id = UUID()
#Published var items: [Result] = [Result(value: "Binding"),
Result(value: "ObservableObject"),
Result(value: "Published")]
let refreshControl: UIRefreshControl
init() {
refreshControl = UIRefreshControl()
refreshControl.addTarget(self, action:
#selector(self.handleRefreshControl),
for: .valueChanged)
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
sender.endRefreshing()
self?.items = [Result(value:"new"), Result(value:"data"), Result(value:"after"), Result(value:"refresh")]
}
}
}
struct ContentView: View {
#ObservedObject var refreshableObject = RefreshableObject()
var body: some View {
NavigationView {
Form {
Section(footer: UIKitView.init { (tableView) in
if tableView.refreshControl == nil {
tableView.refreshControl = self.refreshableObject.refreshControl
}
}){
ForEach(refreshableObject.items) { result in
Text(result.value)
}
}
}
.navigationBarTitle("Nav bar")
}
}
}
Screenshot:
To update from refresh action, binding isUpdateOrdered is being used.
this code is based on code I found in web, couldn't find the author
import Foundation
import SwiftUI
class Model: ObservableObject{
#Published var isUpdateOrdered = false{
didSet{
if isUpdateOrdered{
update()
isUpdateOrdered = false
print("we got him!")
}
}
}
var random = 0
#Published var arr = [Int]()
func update(){
isUpdateOrdered = false
//your update code.... maybe some fetch request or POST?
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
NavigationView {
LegacyScrollViewWithRefresh(isUpdateOrdered: $model.isUpdateOrdered) {
VStack{
if model.arr.isEmpty{
//this is important to fill the
//scrollView with invisible data,
//in other case scroll won't work
//because of the constraints.
//You may get rid of them if you like.
Text("refresh!")
ForEach(1..<100){ _ in
Text("")
}
}else{
ForEach(model.arr, id:\.self){ i in
NavigationLink(destination: Text(String(i)), label: { Text("Click me") })
}
}
}
}.environmentObject(model)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct LegacyScrollViewWithRefresh: UIViewRepresentable {
enum Action {
case idle
case offset(x: CGFloat, y: CGFloat, animated: Bool)
}
typealias Context = UIViewRepresentableContext<Self>
#Binding var action: Action
#Binding var isUpdateOrdered: Bool
private let uiScrollView: UIScrollView
private var uiRefreshControl = UIRefreshControl()
init<Content: View>(isUpdateOrdered: Binding<Bool>, content: Content) {
let hosting = UIHostingController(rootView: content)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
self._isUpdateOrdered = isUpdateOrdered
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
self._action = Binding.constant(Action.idle)
}
init<Content: View>(isUpdateOrdered: Binding<Bool>, #ViewBuilder content: () -> Content) {
self.init(isUpdateOrdered: isUpdateOrdered, content: content())
}
init<Content: View>(isUpdateOrdered: Binding<Bool>, action: Binding<Action>, #ViewBuilder content: () -> Content) {
self.init(isUpdateOrdered: isUpdateOrdered, content: content())
self._action = action
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIScrollView {
uiScrollView.addSubview(uiRefreshControl)
uiRefreshControl.addTarget(context.coordinator, action: #selector(Coordinator.handleRefreshControl(arguments:)), for: .valueChanged)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
switch self.action {
case .offset(let x, let y, let animated):
uiView.setContentOffset(CGPoint(x: x, y: y), animated: animated)
DispatchQueue.main.async {
self.action = .idle
}
default:
break
}
}
class Coordinator: NSObject {
let legacyScrollView: LegacyScrollViewWithRefresh
init(_ legacyScrollView: LegacyScrollViewWithRefresh) {
self.legacyScrollView = legacyScrollView
}
#objc func handleRefreshControl(arguments: UIRefreshControl){
print("refreshing")
self.legacyScrollView.isUpdateOrdered = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2){
arguments.endRefreshing()
//refresh animation will
//always be shown for 2 seconds,
//you may connect this behaviour
//to your update completion
}
}
}
}
There is currently no way to access or modify the underlying UITableView

In SwiftUI, how can I access the current value of .foregroundColor and other modifiers inside my UIViewRepresentable?

Given the following example code:
struct ActivityIndicatorView : UIViewRepresentable {
var style: UIActivityIndicatorView.Style = .medium
func makeUIView(context: UIViewRepresentableContext<ActivityIndicatorView>) -> UIActivityIndicatorView {
return UIActivityIndicatorView(style: style)
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicatorView>) {
uiView.color = UIColor.white // how would I set this to the current .foregroundColor value?
}
}
How do I find out the current value of .foregroundColor(…) in order to render my UIView correctly?
I have read this question but that is from the perspective of inspecting the ModifiedContent from the outside, not the wrapped View.
There is no way to access the foreground colour but you can access the colour scheme and determine the colour of your activity indicator based on that:
struct ActivityIndicatorView : UIViewRepresentable {
#Environment(\.colorScheme) var colorScheme: ColorScheme
//...
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicatorView>) {
switch colorScheme {
case .dark:
uiView.color = .white
case .light:
uiView.color = .black
}
}
}

Resources