I'm working on a SwiftUI app for tvOS. I have a NavigationLink with an image and a line of text. The item works as expected, when in focus it grows. However, if I change the corner radius (the default is too much), then even though the corner radius is smaller and the image and text grow, the image is clipped (the image grows but its bounds remain the same). How can I fix this?
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
ForEach(sections) { section in
VStack(alignment: .leading, spacing: 20) {
// Section title
Text(section.title)
.font(.title)
.padding(.horizontal)
// Items
ScrollView(.horizontal) {
LazyHStack(spacing: 50) {
ForEach(section.items) { item in
NavigationLink {
VideoDetailView(mediaItem: item)
} label: {
AsyncImage(url: item.imageURL) { phase in
switch phase {
case .empty:
// This shows while the image is loading
ProgressView()
.aspectRatio(700/500, contentMode: .fit)
.containerRelativeFrame(.horizontal, count: 6, spacing: 50)
case .success(let image):
// This shows the successfully loaded image
image
.resizable()
.aspectRatio(700/500, contentMode: .fit)
.containerRelativeFrame(.horizontal, count: 6, spacing: 50)
case .failure(_):
// This shows if the image fails to load
Image(systemName: "photo")
.resizable()
.aspectRatio(700/500, contentMode: .fit)
.containerRelativeFrame(.horizontal, count: 6, spacing: 50)
@unknown default:
// Any other
EmptyView()
}
} .cornerRadius(5) // WHen I add this, the image gets clipped
Text(item.title)
//Text(item.subtitle)
}
.buttonStyle(.borderless)
}
}
}
.scrollClipDisabled()
}
}
}
}
}
When you apply .cornerRadius
, it is actually applying a clip shape. In fact, the modifier .cornerRadius
is deprecated, you should use .clipShape
instead.
In order to avoid the clip effect that you describe, the corner radius needs to be applied before the scaling effect that happens when the link gets focus. However, the scaling effect is something that is built into the button style .borderless
, which makes it difficult to intercept. You also need to prevent the larger corner radius from being applied, otherwise you will see no difference. The corner radius is built into the .borderless
button style too.
A way to solve is to use a custom ButtonStyle
, instead of .borderless
.
The scaling effect will need to be built into the custom button style too. This requires knowing when a link has received focus.
A FocusState
variable can be used to detect when a link has focus.
If you want to emulate the scaling effect that you get with .borderless
button style, you will notice that it is only the image that gets scaled, not the text label. However, the label moves down, to stay approximately the same distance from the scaled image.
In order to be able to apply different styling effects to the image and to the text, the two elements can be wrapped as a Label
. Then the custom ButtonStyle
can use a custom LabelStyle
to perform the styling.
Here is a simplified example to show how it can work this way. I have replaced the AsyncImage
with a static image, but I would expect that it should work the same when the Label
is created using an AsyncImage
instead.
struct ItemLabelStyle: LabelStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
VStack {
configuration.icon
.aspectRatio(700/500, contentMode: .fit)
.containerRelativeFrame(.horizontal, count: 6, spacing: 50)
.clipShape(.rect(cornerRadius: 5))
.scaleEffect(isSelected ? 1.25 : 1.0)
configuration.title
.offset(y: isSelected ? 23 : 0)
}
}
}
struct ItemButtonStyle: ButtonStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.labelStyle(ItemLabelStyle(isSelected: isSelected))
}
}
// ContentView
@FocusState private var selectedItem: UUID? // or whatever type the ids have
NavigationStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 50) {
ForEach(section.items) { item in
NavigationLink {
VideoDetailView(mediaItem: item)
} label: {
Label {
Text(item.title)
} icon: {
Image("image2")
.resizable()
}
}
.focused($selectedItem, equals: item.id)
.buttonStyle(ItemButtonStyle(isSelected: selectedItem == item.id))
.animation(.easeInOut, value: selectedItem)
}
}
}
.scrollClipDisabled()
}
Spotlight effect
When button style .borderless
is used, a kind of spotlight effect is also applied to the selected image. This would be another styling effect that the custom label style could add to the image, if you wanted it.
One way to implement the effect is to apply a semi-transparent RadialGradient
(consisting of .white
going to .clear
) as an overlay. Something like:
// ItemLabelStyle
configuration.icon
.aspectRatio(700/500, contentMode: .fit)
.containerRelativeFrame(.horizontal, count: 6, spacing: 50)
.overlay {
RadialGradient(
colors: [.white.opacity(0.25), .clear],
center: UnitPoint(x: 0.5, y: 0.1),
startRadius: 50,
endRadius: 120
)
.opacity(isSelected ? 1 : 0)
}
.clipShape(.rect(cornerRadius: 5))
.scaleEffect(isSelected ? 1.25 : 1.0)