I have an issue with the SwiftUI system layout on Xcode 12.5.1
On a Text View, I'm using a custom transition(), which is based on custom ViewModifiers. In addition, I'm also using a custom id() that allow me to tell the system that this Text View is not the same, therefore triggering a transition animation.
When using these 2 modifiers on a view, its parent and the content itself doesn't respect the system layout expected behaviour. You can see exactly what happens in this video
Expected behaviour
Actual behaviour
Here is my sample project code that you can test yourself :
import SwiftUI
struct ContentView: View {
private let contentArray: [String] = ["QWEQWE", "ASDASD", "TOTO", "FOO", "BAR"]
#State private var content: String = "content"
var body: some View {
VStack {
VStack {
Text(self.content)
// commenting either the transition modifier, or the id modifier, make the system layout work as expected. The problem comes from using both at the same time
.transition(.customTransition)
.id(self.content)
}
.background(Color.blue)
Button("action") {
guard let newValue = self.contentArray.randomElement() else {
print("Failed to get a random element from property contentArray")
return
}
withAnimation {
self.content = newValue
}
}
}
.background(Color.green)
}
}
extension AnyTransition {
static var customTransition: AnyTransition {
// uncomment the following line make the system layout work as expected. The problem comes from AnyTransition.modifier(active:identity:)
// return AnyTransition.slide.combined(with: .opacity)
let outAnimation = AnyTransition.modifier(active: CustomTrailingOffset(percent: 1), identity: CustomTrailingOffset(percent: 0)).combined(with: .opacity)
let inAnimation = AnyTransition.modifier(active: CustomLeadingOffset(percent: 1), identity: CustomLeadingOffset(percent: 0)).combined(with: .opacity)
return AnyTransition.asymmetric(insertion: inAnimation, removal: outAnimation)
}
}
struct CustomLeadingOffset: ViewModifier {
var percent: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geo in
content.offset(CGSize(width: -geo.size.width*self.percent, height: 0.0))
}
}
}
struct CustomTrailingOffset: ViewModifier {
var percent: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geo in
content.offset(CGSize(width: geo.size.width*self.percent, height: 0.0))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewLayout(.device)
.previewDevice("iPhone 12")
}
}
Tested on Xcode 12.5.1
I've found nothing regarding this behaviour during my researches, not on blog posts, not on forums, not on official documentation, that's the reason why I'm posting this question here, in case anyone ever encounter this problem, knows why it occurs, or knows a solution. I've fill a radar (FB9605660) to Apple in parallel but I'm not sure if I'll be informed to their conclusions.
Related
Navigation bar buttons are not tappable after dismissing a sheet in SwiftUI. Below is the steps to reproduce the issue
Present a sheet,
Move the app to background for a short duration (2 seconds)
Resume the app & dismiss the sheet by swiping down
Now the navigation bar button frames are misaligned. Tap is working at different frame than visible frame of the button. This is easily reproducible on iOS 16 simulator, but intermittently on actual iOS devices. Below is the minimal code to reproduce the issue
struct ContentView: View {
#State private var showSheetView = false
var body: some View {
NavigationView {
VStack {
navigationBarView
Color.blue
}
.sheet(isPresented: $showSheetView) {
FilterView()
}
.navigationBarHidden(true)
}
.navigationViewStyle(.stack)
}
private var navigationBarView: some View {
HStack(spacing: 0) {
Spacer()
Button {
showSheetView = true
} label: {
Text("Filter")
.padding()
.background(Color.red)
}
}
}
}
struct FilterView: View {
var body: some View {
Color.green
}
}
I've also struggled a lot with this issue, and it's clearly a bug from apple.
I found an ugly hack to workaround this issue. But to make it work I have to redraw my main view every time I enter background. For my project it's ok until they fix an actual bug. I hope it will help you as well
When you detect app is moving to background, you force app to redraw based on UUID()
struct BackgroundModeSheetBugApp: App {
#State private var id = UUID()
var body: some Scene {
WindowGroup {
ContentView()
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
id = UUID()
}
.id(id)
}
}
}
I've tested it with your code and it's working correctly on both simulator and device
P.S. I also took courage to use your example to submit a bug to Apple
P.P.S. For my app I've also had to wrap my main view to hack UIViewControllerRepresentable to fix the padding issues.
struct UIHackMainTabView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIHostingController<MainTabView> {
let mainTab = MainTabView()
return UIHostingController(rootView: mainTab)
}
func updateUIViewController(_ pageViewController: UIHostingController<MainTabView>, context: Context) {
}
}
Another workaround for now is to make use of presentationDetents()
It's break UI a little, since we're loosing nice shrink effect and it works only on iOS 16, but at least app will work there :(
struct SheetHackModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
.presentationDetents([.fraction(0.99)])
} else {
content
}
}
}
extension View {
func sheetHackModifier() -> some View {
self.modifier(SheetHackModifier())
}
}
So I've seen some approach to read the CGRect of a swiftui view using a technique where you setup a background containing a GeometryReader that is used to read the size of the view the background is set up. My issue is that this doesn't work if the view you're setting the background to has some ifs in it for example. See the code below for reference:
import SwiftUI
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct ChildRectReaderModifier: ViewModifier {
#Binding var rect: CGRect
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy in
Color.clear.preference(
key: RectPreferenceKey.self,
value: proxy.frame(in: .global)
)
}
)
.onPreferenceChange(RectPreferenceKey.self) { preferences in
if preferences != .zero && self.rect != preferences {
self.rect = preferences
}
}
}
}
extension View {
func childRectReader(_ rect: Binding<CGRect>) -> some View {
modifier(ChildRectReaderModifier(rect: rect))
}
}
struct ContentView: View {
#State var showOptionalText = false
#State var rect = CGRect.zero
var body: some View {
VStack {
Text("\(rect.width)")
Button {
showOptionalText.toggle()
} label: {
Text("Toggle")
}
VStack {
Text("Hello, world!")
Text("Hello, world!")
if showOptionalText {
Text("Hello, world!")
}
Text("Hello, world!")
}
.childRectReader($rect)
}
}
}
In the example above, the printed width is saying 0 unless I remove the if inside the inner VStack
Any idea why this happens? or any potential fixes?
As it is used in view modifier the preference is called several times on stack so reduce is active, but it is not correct here (just assign last one overriding all previous).
As I see for this scenario the fix is (tested with Xcode 13.4 / iOS 15.5)
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
// just remove anything from here !!
}
}
but if you wanted to handle multiple usage of this modifier for real views in same hierarchy then you have to use different model for this preference key (array, dictionary).
Working on my first SwiftUI project, and as I started moving some of my more complex views into their own view structs I started getting problems with the views not being redrawn.
As an example, I have the following superview:
struct ContainerView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.padding(.vertical, 10)
.frame(idealHeight: 10)
.padding(.horizontal, 8)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
myDataObject = DataManager.shared.randomData
}
}
Now when this first gets drawn I get the Text view on screen as the myDataObject var is nil, but the .onAppear from that gets called, and myDataStruct gets set with an actual struct. I've added breakpoints in the body variable, and I see that when this happens it gets called again and it goes into the first if clause and fetches the "TheSmallerView" view, but nothing gets redrawn on screen. It still shows the Text view from before.
What am I missing here?
EDIT: Here's the relevant parts of TheSmallerView:
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
EDIT2: Fixed the code to better reflect my actual code.
Try declaring #Binding var myDataStruct: MyDataStruct inside the TheSmallerView view and pass it like this: TheSmallerView(myDataStruct: $myDataStruct) from ContainerView
You are using #ObservedObject in the subview, but that property wrapper is only for classes (and your data is a struct).
You can use #State instead (b/c the data is a struct).
Edit:
The data isn't a struct.
Because it is a class, you should use #StateObject instead of #State.
In lack of complete code I created this simple example based on OPs code, which works fine the way it is expected to. So the problem seems to be somewhere else.
class MyDataObject: ObservableObject {
#Published var number: Int
init() {
number = Int.random(in: 0...1000)
}
}
struct ContentView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
myDataObject = MyDataObject()
}
}
}
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
var body: some View {
Text("The number is: \(myDataObject.number)")
}
}
I have reached an annoying issue with SwiftUI. I have a horizontal pager with vertical scroll views as pages. It is defined as simple as they come,
TabView(selection: $selected) {
ForEach(focus!.list.things) { thing in
FullView(thing: thing).tag(thing)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
and
struct FullView: View {
let thing: Thing
var body: some View {
ScrollView {
VStack {
...
}
}
}
}
This produces a view which does what I want, except it does not reach all the way down below the home indicator.
I can solve this by adding .ignoresSafeArea(edges: .bottom) to the TabView, but that produces another displeasing result where the page indicator collides with the home indicator.
Is there any reasonable way accomplish full height vertical scroll while keeping the index page indicator above the home indicator?
Code to recreate issue:
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
var body: some View {
NavigationView {
TabView() {
Page()
Page()
Page()
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
EDIT: See also #nekno's fantastic additions!
This is possible if you create a custom UIPageControl, manually tag each tab in the TabView, and make sure to keep track of the numberOfPages:
struct PageControlView: UIViewRepresentable {
#Binding var currentPage: Int
#Binding var numberOfPages: Int
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
#State var currentPage = 0
#State var numberOfPages = 3
var body: some View {
NavigationView {
ZStack {
TabView(selection: $currentPage) {
Page().tag(0)
Page().tag(1)
Page().tag(2)
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
VStack {
Spacer()
PageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
}
}
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#Coder-256's answer set me on the right path, and I added a couple enhancements you might find useful.
The UIPageControl normally iterates through the pages when you tap on it. As written, the indicator in the page control was changing, but the pages weren't actually changing, so I added a target for the page control's .valueChanged event.
When setting the current page based on the new changed value, wrapping the assignment in a withAnimation closure ensure the page animates to the next page, otherwise it just replaces the current page instantaneously.
TabView will work with any valid tag values, which just need to conform to Hashable.
To work with the page control, you need those tag values to be convertible to Int values, but it's common practice to use a strongly-typed, named value for tags, so I added support for an enum that conforms to RawRepresentable with a backing type of Int.
Others may find it easier to just use hard-coded integers for the tag values, so if you ever reordered the pages in your TabView you wouldn't have to remember to reorder the cases in your enum, but to each their own.
The UIPageControl and its parent ViewHost that hosts the UIViewRepresentable instance both have auto resizing masks that result in their frames expanding to consume the horizontal space of the containing superview.
Both the page control and the view host participate in hit testing, so they intercept touches to the left and right of the page control when you actually intend to scroll the content underneath.
Adding the allowsHitTesting(false) view modifier eliminates that behavior, but also disables all interaction with the page control, so it breaks the tap/paging functionality.
I played around with various solutions, and the easiest seems to be to just set a frame on the page control that requests a maxWidth and maxHeight of 0, and as a result the view shrinks to its intrinsic content size.
struct PageControlView<T: RawRepresentable>: UIViewRepresentable where T.RawValue == Int {
#Binding var currentPage: T
#Binding var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
#objc func valueChanged(sender: UIPageControl) {
guard let currentPage = T(rawValue: sender.currentPage) else {
return
}
withAnimation {
parent.currentPage = currentPage
}
}
}
}
struct ContentView: View {
#State private var currentPage: Pages = .myFirstPage
#State private var numberOfPages = Pages.allCases.count
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
MyFirstPage()
.tag(Pages.myFirstPage)
MySecondPage()
.tag(Pages.mySecondPage)
MyThirdPage()
.tag(Pages.myThirdPage)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
UIPageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
.frame(maxWidth: 0, maxHeight: 0)
.padding(22) // 22 seems to mimic SwiftUI's `PageIndexView` placement from the bottom edge
}
}
}
extension ContentView {
enum Pages: Int, CaseIterable {
case myFirstPage
case mySecondPage
case myThirdPage
}
}
So i get this error when running my set game app:
Image of the game screen when more of the same cards appear (All cards in the game should be unique)
ForEach<Range<Int>, Int, ModifiedContent<_ConditionalContent<_ConditionalContent<_ConditionalContent<_ConditionalContent
<ZStack<TupleView<(_ShapeView<Squiggle, ForegroundStyle>,
_StrokedShape<Squiggle>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>,
_ClipEffect<Squiggle>>, _StrokedShape<Squiggle>)>>>, _StrokedShape<Squiggle>>,
_ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Capsule, ForegroundStyle>, _StrokedShape<Capsule>)>>,
ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Capsule>>,
_StrokedShape<Capsule>)>>>, _StrokedShape<Capsule>>>, _ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Diamond,
ForegroundStyle>, _StrokedShape<Diamond>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Diamond>>,
_StrokedShape<Diamond>)>>>, _StrokedShape<Diamond>>>,
_FrameLayout>> count (2) != its initial count (1). `ForEach(_:content:)`
should only be used for *constant* data. Instead conform data to `Identifiable`
or use `ForEach(_:id:content:)` and provide an explicit `id`!
It doesnt seem like the card models underneath the view themselves change. Everything but the numberOfShapes on the cards stay the same. So a card that is displayed wrongly will only match if the underlying model is matched correctly and will not match like shown on view.
The error only seem to appear when i either get a set correct and press "show three more cards" or when i display more cards than is visible and then scroll up and down the screen.
The app doesnt stop with the error, but the numberOfShapes on each card changes dynamically (Like on the screenshot above). (This is most clear when i scroll to the top of the screen and then to the bottom and the number of shapes on some cards changes each time)
So i think the problem might be in the card view:
import SwiftUI
struct CardView: View {
var card: Model.Card
var body: some View {
GeometryReader { geometry in
ZStack{
let shape = RoundedRectangle(cornerRadius: 10)
if !card.isMatched {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 4).foregroundColor(card.isSelected ? .red : .blue)
}
VStack {
Spacer(minLength: 0)
//TODO: Error here?
ForEach(0..<card.numberOfShapes.rawValue) { index in
cardShape().frame(height: geometry.size.height/6)
}
Spacer(minLength: 0)
}.padding()
.foregroundColor(setColor())
.aspectRatio(CGFloat(6.0/8.0), contentMode: .fit)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#ViewBuilder private func cardShape() -> some View {
switch card.shape {
case .squiglle: shapeFill(shape: Squiggle())
case .roundedRectangle: shapeFill(shape: Capsule())
case .diamond: shapeFill(shape: Diamond())
}
}
private func setColor() -> Color {
switch card.color {
case .pink: return Color.pink
case .orange: return Color.orange
case .green: return Color.green
}
}
#ViewBuilder private func shapeFill<setShape>(shape: setShape) -> some View
//TODO: se løsning, brug rawvalues for cleanness
where setShape: Shape {
switch card.fill {
case .fill: shape.fillAndBorder()
case .stripes: shape.stripe()
case .none: shape.stroke(lineWidth: 2)
}
}
}
The cardviews are displayed in a flexible gridlayout:
import SwiftUI
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
let maxColumnCount = 6
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
ScrollView{
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
Spacer(minLength: 0)
}
}
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
View:
import SwiftUI
struct SetGameView: View {
#StateObject var game: ViewModel
var body: some View {
VStack{
HStack(alignment: .bottom){
Text("Set Game")
.font(.largeTitle)
}
AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
CardView(card: card)
.padding(4)
.onTapGesture {
game.choose(card)
}
}.foregroundColor(.blue)
.padding(.horizontal)
Button("show 3 more cards"){
game.showThreeMoreCardsFromDeck()
}
}
}
}
Any help solving this is very much appreciated. I have spent so much time on this already.
Your problem is here:
ForEach(0..<card.numberOfShapes.rawValue) { index in
cardShape().frame(height: geometry.size.height/6)
}
This ForEach initializer's first argument is a Range<Int>. The documentation says (emphasis added):
The instance only reads the initial value of the provided data and doesn’t need to identify views across updates. To compute views on demand over a dynamic range, use ForEach/init(_:id:content:).
Because it only reads “the initial value of the provided data”, it's only safe to use if the Range<Int> is constant. If you change the range, the effect is unpredictable. So, starting in Xcode 13, the compiler emits a warning if you pass a non-constant range.
Now, maybe your range (0..<card.numberOfShapes.rawValue) really is constant. But the compiler doesn't know that. (And based on your error, it's not a constant range.)
You can avoid the warning by switching to the initializer that takes an id: argument:
ForEach(0..<card.numberOfShapes.rawValue, id: \.self) { index in
// ^^^^^^^^^^^^^
// add this
cardShape().frame(height: geometry.size.height/6)
}