I am building a custom SegmentedPicker in SwiftUI where the selector adjusts its size to fit the frame of each picker item. I did it already using PreferenceKeys as inspired by this post (Inspecting the View Tree) for uniformly sized items like shown below:
I think I can simplify my implementation considerably and avoid using PreferencyKeys altogether by using a .matchedGeometryEffect(). My idea was to present a selector behind each item only when that item has been selected and sync the transition using the .matchedGeometryEffect(). Almost everything is working except for an issue where the selector will be in front of the previously selected item. I tried explicitly setting the zIndex, but it does not seem to affect the result:
The code:
struct MatchedGeometryPicker: View {
#Namespace private var animation
#Binding var selection: Int
let items: [String]
var body: some View {
HStack {
ForEach(items.indices) { index in
ZStack {
if isSelected(index) {
Color.gray.clipShape(Capsule())
.matchedGeometryEffect(id: "selector", in: animation)
.animation(.easeInOut)
.zIndex(0)
}
itemView(for: index)
.padding(7)
.zIndex(1)
}
.fixedSize()
}
}
.padding(7)
}
func itemView(for index: Int) -> some View {
Text(items[index])
.frame(minWidth: 0, maxWidth: .infinity)
.foregroundColor(isSelected(index) ? .black : .gray)
.font(.caption)
.onTapGesture { selection = index }
}
func isSelected(_ index: Int) -> Bool { selection == index }
}
And in ContentView:
struct ContentView: View {
#State private var selection = 0
let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long item 5"]
var body: some View {
MatchedGeometryPicker(selection: $selection, items: pickerItems)
.background(Color.gray.opacity(0.10).clipShape(Capsule()))
.padding(.horizontal, 5)
}
}
Any ideas how to fix this?
I managed to solve all the animation issues I had with the picker implementation that uses PreferenceKeys when the items have different frame sizes. This does not solve the issue I have with the zIndex and the .matchedGeometryEffect(), so I will not accept my own answer, but I'll post it as a reference in case anyone needs it in the future.
The code:
public struct PKPicker: View {
#Binding var selection: Int
#State private var frames: [CGRect] = []
let items: [String]
public init(
selection: Binding<Int>,
items: [String])
{
self._selection = selection
self._frames = State(wrappedValue: Array<CGRect>(repeating: CGRect(),
count: items.count))
self.items = items
}
public var body: some View {
ZStack(alignment: .topLeading) {
selector
HStack {
ForEach(items.indices) { index in
itemView(for: index)
}
}
}
.onPreferenceChange(PKPickerItemPreferenceKey.self) { preferences in
preferences.forEach { frames[$0.id] = $0.frame }
}
.coordinateSpace(name: "picker2")
}
var selector: some View {
Color.gray.opacity(0.25).clipShape(Capsule())
.frame(width: frames[selection].size.width,
height: frames[selection].size.height)
.offset(x: frames[selection].minX, y: frames[selection].minY)
}
func itemView(for index: Int) -> some View {
Text(items[index])
.fixedSize()
.padding(7)
.foregroundColor(isSelected(index) ? .black : .gray)
.font( .caption)
.onTapGesture { selection = index }
.background(PKPickerItemPreferenceSetter(id: index))
}
func isSelected(_ index: Int) -> Bool {
index == selection
}
}
struct PKPickerItemPreferenceData: Equatable {
let id: Int
let frame: CGRect
}
struct PKPickerItemPreferenceKey: PreferenceKey {
typealias Value = [PKPickerItemPreferenceData]
static var defaultValue: [PKPickerItemPreferenceData] = []
static func reduce(
value: inout [PKPickerItemPreferenceData],
nextValue: () -> [PKPickerItemPreferenceData])
{
value.append(contentsOf: nextValue())
}
}
struct PKPickerItemPreferenceSetter: View {
let id: Int
let coordinateSpace = CoordinateSpace.named("picker2")
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: PKPickerItemPreferenceKey.self,
value: [PKPickerItemPreferenceData(
id: id, frame: geometry.frame(in: coordinateSpace))])
}
}
}
And in ContentView
struct ContentView: View {
#State private var selection = 0
let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long Item 5"]
var body: some View {
PKPicker(selection: $selection.animation(.easeInOut), items: pickerItems)
.padding(7)
.background(Color.gray.opacity(0.10).clipShape(Capsule()))
.padding(5)
}
Result:
Related
Recently ran into an issue trying to perform Hero animation using matchedGeometryEffect in SwiftUI. My issue is that setting id for matchedGeometryEffect effect dynamically isn't working as expected.
This is what I have so far:
import SwiftUI
struct HeroAnimationTest: View {
let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
#State var selectedItemInRowIndex: Int? = nil
#Namespace var namespace
var body: some View {
List {
ForEach(items, id: \.id) { item in
ItemListRow(namespace: namespace, item: item) { tappedItem in
withAnimation {
selectedItemInRowIndex = tappedItem.id
}
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(h: 16, v: 8))
}
.animation(.spring(), value: selectedItemInRowIndex)
.scrollIndicators(.never)
.listStyle(.plain)
.overlay {
if selectedItemInRowIndex != nil {
largeGreen
}
}
}
var largeGreen: some View {
ZStack {
Color.black
.onTapGesture {
withAnimation {
selectedItemInRowIndex = nil
}
}
Color.green
.frame(width: 200, height: 400)
.matchedGeometryEffect(id: selectedItemInRowIndex, in: namespace)
Text("ID -> \(selectedItemInRowIndex ?? 0)")
}
}
}
struct HeroAnimationTest_Previews: PreviewProvider {
static var previews: some View {
HeroAnimationTest()
}
}
struct Item {
let id: Int
}
struct ItemListRow: View {
#State var enlargeElement = false
let namespace: Namespace.ID
let item: Item
let onGreenTap: (Item) -> Void
var body: some View {
HStack {
Text("ID -> \(item.id)")
VStack {
Color.green
}
.frame(width: 100, height: 40)
.matchedGeometryEffect(id: item.id, in: namespace)
.onTapGesture {
onGreenTap(item)
}
VStack {
Color.yellow
}
.frame(width: 100, height: 40)
}
}
}
Current result:
I tried to hard-code id for largeGreen inside .matchedGeometryEffect(id: 3, in: namespace) to check if animation would work, and it does:
Animation with hard-coded id is the expected result, but obviously it's only working for the 3rd row. Is it even possible to achieve this effect for a green container in every row?
I'd really appreciate if anyone could take a look and give me some hint of what I'm missing here. I've been looking at this for a few hours now, but still can't figure out what went wrong.
Okay, I finally figured it out. It turns out that in order for matchedGeometryEffect to work, its id needs to be unwrapped, assigning optional just won't work.
So, what I did was:
Extracted largeGreen to a ExpandedLargeGreen with selectedItemIdx &
show parameters with #Binding property wrapper, and namespace.
And then inside .overlay closure within HeroAnimationTest unwrapped
optional Binding selectedItemInRowIndex. In case it's not nil and
show is set to true ExpandedLargeGreen will be displayed with
proper animation.
Also set animation value to show instead of selectedItemInRowIndex.
Full code:
struct HeroAnimationTest: View {
let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
#State var selectedItemInRowIndex: Int? = nil
#State var show = false
#Namespace var namespace
var body: some View {
ZStack {
List {
ForEach(items, id: \.id) { item in
ItemListRow(namespace: namespace, item: item) { tappedItem in
withAnimation {
selectedItemInRowIndex = tappedItem.id
show.toggle()
}
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(h: 16, v: 8))
}
.animation(.spring(), value: show)
.scrollIndicators(.never)
.listStyle(.plain)
}
.overlay {
if let selectedIdx = Binding($selectedItemInRowIndex), show {
ExpandedLargeGreen(selectedItemIdx: selectedIdx, show: $show, namespace: namespace)
}
}
}
}
struct ExpandedLargeGreen: View {
#Binding var selectedItemIdx: Int
#Binding var show: Bool
var namespace: Namespace.ID
var body: some View {
ZStack {
Color.black
.onTapGesture {
withAnimation {
show.toggle()
}
}
Color.green
.frame(width: 200, height: 400)
.matchedGeometryEffect(id: selectedItemIdx, in: namespace)
Text("ID -> \(selectedItemIdx)")
}
}
}
struct HeroAnimationTest_Previews: PreviewProvider {
static var previews: some View {
HeroAnimationTest()
}
}
struct Item {
let id: Int
}
struct ItemListRow: View {
#State var enlargeElement = false
let namespace: Namespace.ID
let item: Item
let onGreenTap: (Item) -> Void
var body: some View {
HStack {
Text("ID -> \(item.id)")
VStack {
Color.green
}
.frame(width: 100, height: 40)
.matchedGeometryEffect(id: item.id, in: namespace)
.onTapGesture {
onGreenTap(item)
}
VStack {
Color.yellow
}
.frame(width: 100, height: 40)
}
}
}
Final result:
Hi I have a Stack of Hstacks that consist of a Text and a slider. The slider width extends to the edge of the text in front of it but I want them all to have the same width and appear in a straight column.
Like this below.
This is how I am forming the stacks.
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name).font(.custom("Gill Sans", size: 12)).padding(.trailing).foregroundColor(.gray)
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
this is the view where I am forming the sliders. Can someone pls help
struct MyNodeView : View {
#Binding var myNode : Sliders
var body: some View {
HStack {
Text("\(String(format: "%.f", myNode.percent))%").font(.footnote)
Slider(value: $myNode.percent, in: 0 ... 100)
}
}
}
To make sure all the text is the same width, the simplest way is .frame(width:).
struct Sliders {
var name = "Name"
var percent = CGFloat(100)
}
struct ContentView: View {
#State var defensiveLayers = [
Sliders(name: "Long name", percent: 80),
Sliders(name: "Name", percent: 70),
Sliders(name: "Ok name", percent: 65),
Sliders(name: "Hi", percent: 15),
Sliders(name: "Hello", percent: 45),
]
var body: some View {
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name).font(.custom("Gill Sans", size: 12)).padding(.trailing).foregroundColor(.gray)
.frame(width: 100) /// add frame here!
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
}
}
struct MyNodeView : View {
#Binding var myNode: Sliders
var body: some View {
HStack {
Text("\(String(format: "%.f", myNode.percent))%").font(.footnote)
Slider(value: $myNode.percent, in: 0 ... 100)
}
}
}
Result:
You could calculate the required width for the longest Text and apply that particular width to all the leading Text views in all the HStacks unifying them in the width. I've created a helper method for finding the required width for any SwiftUI View.
Helper:
extension View {
func viewSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
Usage:
#State private var textFrameWidth: CGFloat = 0
var body: some View {
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name)
.font(.custom("Gill Sans", size: 12))
.padding(.trailing)
.foregroundColor(.gray)
.viewSize { size in
textFrameWidth = textFrameWidth < size.width ? size.width : textFrameWidth
}
.frame(width: textFrameWidth)
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
}
I'm trying to create a List and allow only one item to be selected at a time. How would I do so in a ForEach loop? I can select multiple items just fine, but the end goal is to have only one checkmark in the selected item in the List. It may not even be the proper way to handle what I'm attempting.
struct ContentView: View {
var body: some View {
NavigationView {
List((1 ..< 4).indices, id: \.self) { index in
CheckmarkView(index: index)
.padding(.all, 3)
}
.listStyle(PlainListStyle())
.navigationBarTitleDisplayMode(.inline)
//.environment(\.editMode, .constant(.active))
}
}
}
struct CheckmarkView: View {
let index: Int
#State var check: Bool = false
var body: some View {
Button(action: {
check.toggle()
}) {
HStack {
Image("Image-\(index)")
.resizable()
.frame(width: 70, height: 70)
.cornerRadius(13.5)
Text("Example-\(index)")
Spacer()
if check {
Image(systemName: "checkmark")
.resizable()
.frame(width: 12, height: 12)
}
}
}
}
}
You'll need something to store all of the states instead of storing it per-checkmark view, because of the requirement to just have one thing checked at a time. I made a little example where the logic is handled in an ObservableObject and passed to the checkmark views through a custom Binding that handles checking/unchecking states:
struct CheckmarkModel {
var id = UUID()
var state = false
}
class StateManager : ObservableObject {
#Published var checkmarks = [CheckmarkModel(), CheckmarkModel(), CheckmarkModel(), CheckmarkModel()]
func singularBinding(forIndex index: Int) -> Binding<Bool> {
Binding<Bool> { () -> Bool in
self.checkmarks[index].state
} set: { (newValue) in
self.checkmarks = self.checkmarks.enumerated().map { itemIndex, item in
var itemCopy = item
if index == itemIndex {
itemCopy.state = newValue
} else {
//not the same index
if newValue {
itemCopy.state = false
}
}
return itemCopy
}
}
}
}
struct ContentView: View {
#ObservedObject var state = StateManager()
var body: some View {
NavigationView {
List(Array(state.checkmarks.enumerated()), id: \.1.id) { (index, item) in //<-- here
CheckmarkView(index: index + 1, check: state.singularBinding(forIndex: index))
.padding(.all, 3)
}
.listStyle(PlainListStyle())
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct CheckmarkView: View {
let index: Int
#Binding var check: Bool //<-- Here
var body: some View {
Button(action: {
check.toggle()
}) {
HStack {
Image("Image-\(index)")
.resizable()
.frame(width: 70, height: 70)
.cornerRadius(13.5)
Text("Example-\(index)")
Spacer()
if check {
Image(systemName: "checkmark")
.resizable()
.frame(width: 12, height: 12)
}
}
}
}
}
What's happening:
There's a CheckmarkModel that has an ID for each checkbox, and the state of that box
StateManager keeps an array of those models. It also has a custom binding for each index of the array. For the getter, it just returns the state of the model at that index. For the setter, it makes a new copy of the checkbox array. Any time a checkbox is set, it unchecks all of the other boxes. I also kept your original behavior of allowing nothing to be checked
The List now gets an enumeration of the state.checkmarks -- using enumerated lets me keep your previous behavior of being able to pass an index number to the checkbox view
Inside the ForEach, the custom binding from before is created and passed to the subview
In the subview, instead of using #State, #Binding is used (this is what the custom Binding is passed to)
List {
ForEach(0 ..< RemindTimeType.allCases.count) {
index in CheckmarkView(title:getListTitle(index), index: index, markIndex: $markIndex)
.padding(.all, 3)
}.listRowBackground(Color.clear)
}
struct CheckmarkView: View {
let title: String
let index: Int
#Binding var markIndex: Int
var body: some View {
Button(action: {
markIndex = index
}) {
HStack {
Text(title)
.foregroundColor(Color.white)
.font(.custom(FontEnum.Regular.fontName, size: 14))
Spacer()
if index == markIndex {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(hex: 0xe6c27c))
}
}
}
}
}
We can benefit from binding collections of Swift 5.5.
import SwiftUI
struct CheckmarkModel: Identifiable, Hashable {
var id = UUID()
var state = false
}
class StateManager : ObservableObject {
#Published var checkmarks = [CheckmarkModel(), CheckmarkModel(), CheckmarkModel(), CheckmarkModel()]
}
struct SingleSelectionList<Content: View>: View {
#Binding var items: [CheckmarkModel]
#Binding var selectedItem: CheckmarkModel?
var rowContent: (CheckmarkModel) -> Content
#State var previouslySelectedItemNdx: Int?
var body: some View {
List(Array($items.enumerated()), id: \.1.id) { (ndx, $item) in
rowContent(item)
.modifier(CheckmarkModifier(checked: item.id == self.selectedItem?.id))
.contentShape(Rectangle())
.onTapGesture {
if let prevIndex = previouslySelectedItemNdx {
items[prevIndex].state = false
}
self.selectedItem = item
item.state = true
previouslySelectedItemNdx = ndx
}
}
}
}
struct CheckmarkModifier: ViewModifier {
var checked: Bool = false
func body(content: Content) -> some View {
Group {
if checked {
ZStack(alignment: .trailing) {
content
Image(systemName: "checkmark")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.green)
.shadow(radius: 1)
}
} else {
content
}
}
}
}
struct ContentView: View {
#ObservedObject var state = StateManager()
#State private var selectedItem: CheckmarkModel?
var body: some View {
VStack {
Text("Selected Item: \(selectedItem?.id.description ?? "Select one")")
Divider()
SingleSelectionList(items: $state.checkmarks, selectedItem: $selectedItem) { item in
HStack {
Text(item.id.description + " " + item.state.description)
Spacer()
}
}
}
}
}
A bit simplified version
struct ContentView: View {
#ObservedObject var state = StateManager()
#State private var selection: CheckmarkModel.ID?
var body: some View {
List {
ForEach($state.checkmarks) { $item in
SelectionCell(item: $item, selectedItem: $selection)
.onTapGesture {
if let ndx = state.checkmarks.firstIndex(where: { $0.id == selection}) {
state.checkmarks[ndx].state = false
}
selection = item.id
item.state = true
}
}
}
.listStyle(.plain)
}
}
struct SelectionCell: View {
#Binding var item: CheckmarkModel
#Binding var selectedItem: CheckmarkModel.ID?
var body: some View {
HStack {
Text(item.id.description + " " + item.state.description)
Spacer()
if item.id == selectedItem {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
A version that uses internal List's selected mark and selection:
import SwiftUI
struct CheckmarkModel: Identifiable, Hashable {
var name: String
var state: Bool = false
var id = UUID()
}
class StateManager : ObservableObject {
#Published var checkmarks = [CheckmarkModel(name: "Name1"), CheckmarkModel(name: "Name2"), CheckmarkModel(name: "Name3"), CheckmarkModel(name: "Name4")]
}
struct ContentView: View {
#ObservedObject var state = StateManager()
#State private var selection: CheckmarkModel.ID?
#State private var selectedItems = [CheckmarkModel]()
var body: some View {
VStack {
Text("Items")
List($state.checkmarks, selection: $selection) { $item in
Text(item.name + " " + item.state.description)
}
.onChange(of: selection) { s in
for index in state.checkmarks.indices {
if state.checkmarks[index].state == true {
state.checkmarks[index].state = false
}
}
selectedItems = []
if let ndx = state.checkmarks.firstIndex(where: { $0.id == selection}) {
state.checkmarks[ndx].state = true
selectedItems = [state.checkmarks[ndx]]
print(selectedItems)
}
}
.environment(\.editMode, .constant(.active))
Divider()
List(selectedItems) {
Text($0.name + " " + $0.state.description)
}
}
Text("\(selectedItems.count) selections")
}
}
I am trying to create a paged scrollview
I have used a TabView to create this.
here is my code
struct TT: Identifiable {
let id = UUID()
var v: String
}
struct TTest: View {
#State var currentIndex = 0
#State var data = [
TT(v: "0")
]
var body: some View {
VStack {
SwiftUI.TabView(selection: $currentIndex) {
ForEach(data.indexed(), id: \.1.id) { index, value in
TTestConsomer(data: $data[index]).tag(index)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
Spacer()
HStack {
Image(systemName: "plus")
.resizable()
.frame(width: 50, height: 50)
.onTapGesture(perform: add)
Image(systemName: "minus")
.resizable()
.frame(width: 50, height: 5)
.onTapGesture(perform: delete)
}
Text("\(currentIndex + 1)/\(data.count)")
}
}
func add() {
data.append(TT(v: "\(data.count)") )
}
func delete() {
data.remove(at: currentIndex)
}
}
struct TTestConsomer: View {
#Binding var data: TT
var body: some View {
Text(data.v)
.padding(30)
.border(Color.black)
}
}
// You also need this extension
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { base.startIndex }
var endIndex: Index { base.endIndex }
func index(after i: Index) -> Index {
base.index(after: i)
}
func index(before i: Index) -> Index {
base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: base[position])
}
}
When I click on the + button, I can add more tabs.
But when I click on the delete button, My app crashes.
What is the problem here? There does not seem to be anything wrong with the code.
No, I don't think you're doing anything wrong. I submitted an issue to Apple about a related issue on the TabView a few months ago but never heard anything back. The debug comes from the collection view coordinator:
#0 0x00007fff57011ca4 in Coordinator.collectionView(_:cellForItemAt:) ()
It seems like after removing an item, the underlying collection view doesn't get updated. In UIKit, we would probably call .reloadData() to force it to update. In SwiftUI, you could redraw the collection view on the screen to force it to update. Obviously, this affects the UI and isn't a perfect solution.
struct TabViewTest: View {
#State var isLoading: Bool = false
#State var data: [String] = [
"one",
"two"
]
var body: some View {
if !isLoading, !data.isEmpty {
TabView {
ForEach(data, id: \.self) { item in
Text(item)
.tabItem { Text(item) }
}
}
.tabViewStyle(PageTabViewStyle())
.overlay(
HStack {
Text("Add")
.onTapGesture {
add()
}
Text("remove")
.onTapGesture {
remove()
}
}
, alignment: .bottom
)
}
}
func add() {
data.append("three")
print(data)
}
func remove() {
isLoading = true
data.removeFirst()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isLoading = false
}
print(data)
}
}
struct TabVIewTest_Previews: PreviewProvider {
static var previews: some View {
TabViewTest()
}
}
In a list, i need to know which item is selected and this item have to be clickable.
This is what i try to do:
| item1 | info of the item3 (selected) |
| item2 | |
|*item3*| |
| item4 | |
I can make it with .focusable() but it's not clickable.
Button or NavigationLink works but i can't get the current item selected.
When you use Button or NavigationLink .focusable don't hit anymore.
So my question is:
How i can get the current item selected (so i can display more infos about this item) and make it clickable to display the next view ?
Sample code 1: Focusable works but .onTap doesn't exists on tvOS
import SwiftUI
struct TestList: Identifiable {
var id: Int
var name: String
}
let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)") }
struct SwiftUIView : View {
var testList: [TestList]
var body: some View {
List {
ForEach(testList) { txt in
TestRow(row: txt)
}
}
}
}
struct TestRow: View {
var row: TestList
#State private var backgroundColor = Color.clear
var body: some View {
Text(row.name)
.focusable(true) { isFocused in
self.backgroundColor = isFocused ? Color.green : Color.blue
if isFocused {
print(self.row.name)
}
}
.background(self.backgroundColor)
}
}
Sample code 2: items are clickable via NavigationLink but there is no way to get the selected item and .focusable is not called anymore.
import SwiftUI
struct TestList: Identifiable {
var id: Int
var name: String
}
let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)") }
struct SwiftUIView : View {
var testList: [TestList]
var body: some View {
NavigationView {
List {
ForEach(testList) { txt in
NavigationLink(destination: Text("Destination")) {
TestRow(row: txt)
}
}
}
}
}
}
struct TestRow: View {
var row: TestList
#State private var backgroundColor = Color.clear
var body: some View {
Text(row.name)
.focusable(true) { isFocused in
self.backgroundColor = isFocused ? Color.green : Color.blue
if isFocused {
print(self.row.name)
}
}
.background(self.backgroundColor)
}
}
It seems like a major oversite to me you can't attach a click event in swiftui for tvos. I've come up with a hack that allows you to make most swiftui components selectable and clickable. Hope it helps.
First I need to make a UIView that captures the events.
class ClickableHackView: UIView {
weak var delegate: ClickableHackDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if event?.allPresses.map({ $0.type }).contains(.select) ?? false {
delegate?.clicked()
} else {
superview?.pressesEnded(presses, with: event)
}
}
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
delegate?.focus(focused: isFocused)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFocused: Bool {
return true
}
}
The clickable delegate:
protocol ClickableHackDelegate: class {
func focus(focused: Bool)
func clicked()
}
Then make a swiftui extension for my view
struct ClickableHack: UIViewRepresentable {
#Binding var focused: Bool
let onClick: () -> Void
func makeUIView(context: UIViewRepresentableContext<ClickableHack>) -> UIView {
let clickableView = ClickableHackView()
clickableView.delegate = context.coordinator
return clickableView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<ClickableHack>) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, ClickableHackDelegate {
private let control: ClickableHack
init(_ control: ClickableHack) {
self.control = control
super.init()
}
func focus(focused: Bool) {
control.focused = focused
}
func clicked() {
control.onClick()
}
}
}
Then I make a friendlier swiftui wrapper so I can pass in any kind of component I want to be focusable and clickable
struct Clickable<Content>: View where Content : View {
let focused: Binding<Bool>
let content: () -> Content
let onClick: () -> Void
#inlinable public init(focused: Binding<Bool>, onClick: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.focused = focused
self.onClick = onClick
}
var body: some View {
ZStack {
ClickableHack(focused: focused, onClick: onClick)
content()
}
}
}
Example usage:
struct ClickableTest: View {
#State var focused1: Bool = false
#State var focused2: Bool = false
var body: some View {
HStack {
Clickable(focused: self.$focused1, onClick: {
print("clicked 1")
}) {
Text("Clickable 1")
.foregroundColor(self.focused1 ? Color.red : Color.black)
}
Clickable(focused: self.$focused2, onClick: {
print("clicked 2")
}) {
Text("Clickable 2")
.foregroundColor(self.focused2 ? Color.red : Color.black)
}
}
}
}
mark a view as focusable true (stating you want it to be able to have a focus), and implement onFocusChange to save the focus state
.focusable(true, onFocusChange: { focused in
isFocused = focused
})
you need to save the isFocused as a #State var
#State var isFocused: Bool = false
then style your View based on the isFocused value
.scaleEffect(isFocused ? 1.2 : 1.0)
here is a fully working example:
struct MyCustomFocus: View {
#State var isFocused: Bool = false
var body: some View {
Text("Select Me")
.focusable(true, onFocusChange: { focused in
isFocused = focused
})
.shadow(color: Color.black, radius: isFocused ? 10 : 5, x: 5, y: isFocused ? 20 : 5)
.scaleEffect(isFocused ? 1.2 : 1.0)
.animation(.spring().speed(2))
.padding()
}
}
struct CustomFocusTest: View {
var body: some View {
VStack
{
HStack
{
MyCustomFocus()
MyCustomFocus()
MyCustomFocus()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.yellow)
.ignoresSafeArea(.all) // frame then backround then ignore for full screen background (order matters)
.edgesIgnoringSafeArea(.all)
}
}
I haven't had much luck with custom button styles on tvOS, unfortunately.
However, to create a focusable, selectable custom view in SwiftUI on tvOS you can set the button style to plain. This allows you to keep the nice system-provided focus and selection animations, while you provide the destination and custom layout. Just add the .buttonStyle(PlainButtonStyle()) modifier to your NavigationLink:
struct VideoCard: View {
var body: some View {
NavigationLink(
destination: Text("Video player")
) {
VStack(alignment: .leading, spacing: .zero) {
Image(systemName: "film")
.frame(width: 356, height: 200)
.background(Color.white)
Text("Video Title")
.foregroundColor(.white)
.padding(10)
}
.background(Color.primary)
.frame(maxWidth: 400)
}
.buttonStyle(PlainButtonStyle())
}
}
Here's a screenshot of what it looks like in the simulator.
Clicking the button on the Siri remote, or Enter or a keyboard, should work as you'd expect.