Search code examples
layoutswiftuitvos

Struggling with layout in SwiftUI on TVOS


I have a workable UI in TVOS using SwiftUI, but I can't figure out how to make it lay out properly.

Goals:

  1. Screen is full screen, not inset in safe area
  2. Album image is square, aspect ratio preserved, fills top-to-bottom
  3. Album and artist text lays out comfortably, near the album art
import SwiftUI

struct ContentView: View {
    @ObservedObject var ds = DataStore()
    
    var body: some View {
        HStack(alignment: .top) {
            if (ds.currentRoom.albumImage != nil) {
                Image(uiImage: ds.currentRoom.albumImage!)
                    .resizable()
                    .aspectRatio(contentMode: ContentMode.fit)
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                    .background(Color.black)
                
            }
            VStack(alignment: .leading, spacing: 8) {
                Text(ds.currentRoom.artist ?? "?")
                    .font(.system(.title, design: .default))
                    .bold()
                    .foregroundColor(Color.gray)
                    .padding(.top, 100)
                Text(ds.currentRoom.title ?? "?")
                    .font(.system(.headline, design: .default))
                    .foregroundColor(Color.gray)
                    .padding(.bottom, 100)
                Button(action: { print ("pressed!" )} ) {
                    Image(systemName: "playpause")
                        .font(.system(.title, design: .default))
                        .foregroundColor(Color.gray)
                        .padding(30)
                        .overlay(
                            RoundedRectangle(cornerRadius: 16)
                                .stroke(Color.green, lineWidth: 4)
                    )
                }
            }
        }
        .padding(20)
        .background(Color.black)
        .edgesIgnoringSafeArea(.all)
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

In case it helps, here are the relevant helper and DataSource methods:

The DataStore() class has this method:

import SwiftUI
import Combine

class CurrentRoom {
    var artist: String?
    var title: String?
    var album: String?
    var albumArtURI: String?
    var absoluteAlbumArtURI: String?
    var albumImage: UIImage?
}

class DataStore: ObservableObject {
    @Published var currentRoom: CurrentRoom = CurrentRoom()
        
    init() {
        getCurrentRoom()
    }
    
    func getCurrentRoom() {
        currentRoom.artist = "Eilen Jewell"
        currentRoom.album = "Sundown over Ghost Town"
        currentRoom.title = "Half-Broke Horse"
        currentRoom.absoluteAlbumArtURI = "https://images-na.ssl-images-amazon.com/images/I/71mkKfTQD0L._SX425_.jpg"
        currentRoom.albumImage = Api().getImageDataFromURI(UrlAsString: currentRoom.absoluteAlbumArtURI)
    }
}

struct DataStore_Previews: PreviewProvider {
    static var previews: some View {
        /*@START_MENU_TOKEN@*/Text("Hello, World!")/*@END_MENU_TOKEN@*/
    }
}

Finally:

class Api {
    func getImageDataFromURI(UrlAsString: String?) -> UIImage? {
        if let URI = UrlAsString {
            if URI.starts(with: "http") {
                if let url = URL(string: URI) {
                    let data = try? Data(contentsOf: url)

                    if let imageData = data {
                        return UIImage(data: imageData)
                    }
                }
            }
        }
        return nil
    }
}

Goal: enter image description here


Solution

  • You can either go with Asperis answer, or, if ContentMode.Fit is important to you (as you don't want to clip the image), just remove the width part of the frame modifier at your Image, and then add a Spacer behind the VStack, which will consume the rest:

    struct ContentView: View {
    @ObservedObject var ds = DataStore()
    
    var body: some View {
        HStack(alignment: .top) {
                if (ds.currentRoom.albumImage != nil) {
                    Image(uiImage: ds.currentRoom.albumImage!)
                        .resizable()
                        .aspectRatio(contentMode: ContentMode.fit)
                        .frame(minHeight: 0, maxHeight: .infinity)
                        .background(Color.black)
                    
                }
                VStack(alignment: .leading, spacing: 8) {
                    Text(ds.currentRoom.artist ?? "?")
                        .font(.system(.title, design: .default))
                        .bold()
                        .foregroundColor(Color.gray)
                        .padding(.top, 100)
                    Text(ds.currentRoom.title ?? "?")
                        .font(.system(.headline, design: .default))
                        .foregroundColor(Color.gray)
                        .padding(.bottom, 100)
                    Button(action: { print ("pressed!" )} ) {
                        Image(systemName: "playpause")
                            .font(.system(.title, design: .default))
                            .foregroundColor(Color.gray)
                            .padding(30)
                            .overlay(
                                RoundedRectangle(cornerRadius: 16)
                                    .stroke(Color.green, lineWidth: 4)
                        )
                    }
                }
                .padding(.leading, 10)
            Spacer()
            }
        .padding(20)
        .background(Color.black)
        .edgesIgnoringSafeArea(.all)
        }
    }
    

    This will result in this image: Correct layout