How to scroll through items in scroll view using keyboard arrows in SwiftUI? - ios

I've built a view that has scroll view of horizontal type with HStack for macOS app. Is there a way to circle those items using keyboard arrows?
(I see that ListView has a default behavior but for other custom view types there are none)
click here to see the screenshot
var body: some View {
VStack {
ScrollView(.horizontal, {
HStack {
ForEach(items.indices, id: \.self) { index in
//custom view for default state and highlighted state
}
}
}
}
}
any help is appreciated :)

You could try this example code, using my previous post approach, but with a horizontal scrollview instead of a list. You will have to adjust the code to your particular app. My approach consists only of a few lines of code that monitors the key events.
import Foundation
import SwiftUI
import AppKit
struct ContentView: View {
let fruits = ["apples", "pears", "bananas", "apricot", "oranges"]
#State var selection: Int = 0
#State var keyMonitor: Any?
var body: some View {
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 0) {
ForEach(fruits.indices, id: \.self) { index in
VStack {
Image(systemName: "globe")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.padding(10)
Text(fruits[index]).tag(index)
}
.background(selection == index ? Color.red : Color.clear)
.padding(10)
}
}
}
.onAppear {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { nsevent in
if nsevent.keyCode == 124 { // arrow right
selection = selection < fruits.count ? selection + 1 : 0
} else {
if nsevent.keyCode == 123 { // arrow left
selection = selection > 1 ? selection - 1 : 0
}
}
return nsevent
}
}
.onDisappear {
if keyMonitor != nil {
NSEvent.removeMonitor(keyMonitor!)
keyMonitor = nil
}
}
}
}

Approach I used
Uses keyboard shortcuts on a button
Alternate approach
To use commands (How to detect keyboard events in SwiftUI on macOS?)
Code:
Model
struct Item: Identifiable {
var id: Int
var name: String
}
class Model: ObservableObject {
#Published var items = (0..<100).map { Item(id: $0, name: "Item \($0)")}
}
Content
struct ContentView: View {
#StateObject private var model = Model()
#State private var selectedItemID: Int?
var body: some View {
VStack {
Button("move right") {
moveRight()
}
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
ScrollView(.horizontal) {
LazyHGrid(rows: [GridItem(.fixed(180))]) {
ForEach(model.items) { item in
ItemCell(
item: item,
isSelected: item.id == selectedItemID
)
.onTapGesture {
selectedItemID = item.id
}
}
}
}
}
}
private func moveRight() {
if let selectedItemID {
if selectedItemID + 1 >= model.items.count {
self.selectedItemID = model.items.last?.id
} else {
self.selectedItemID = selectedItemID + 1
}
} else {
selectedItemID = model.items.first?.id
}
}
}
Cell
struct ItemCell: View {
let item: Item
let isSelected: Bool
var body: some View {
ZStack {
Rectangle()
.foregroundColor(isSelected ? .yellow : .blue)
Text(item.name)
}
}
}

Related

Show full screen view overlaying also TabBar

I'm trying to show a view with a loader in full screen. I want also to overlay the TabBar, but I don't know how to do it. Let me show my code.
This is ProgressViewModifier.
// MARK: - View - Extension
extension View {
/// Show a loader binded to `isShowing` parameter.
/// - Parameters:
/// - isShowing: `Bool` value to indicate if the loader is to be shown or not.
/// - text: An optional text to show below the spinning circle.
/// - color: The color of the spinning circle.
/// - Returns: The loader view.
func progressView(
isShowing: Binding <Bool>,
backgroundColor: Color = .black,
dimBackground: Bool = false,
text : String? = nil,
loaderColor : Color = .white,
scale: Float = 1,
blur: Bool = false) -> some View {
self.modifier(ProgressViewModifier(
isShowing: isShowing,
backgroundColor: backgroundColor,
dimBackground: dimBackground,
text: text,
loaderColor: loaderColor,
scale: scale,
blur: blur)
)
}
}
// MARK: - ProgressViewModifier
struct ProgressViewModifier : ViewModifier {
#Binding var isShowing : Bool
var backgroundColor: Color
var dimBackground: Bool
var text : String?
var loaderColor : Color
var scale: Float
var blur: Bool
func body(content: Content) -> some View {
ZStack { content
if isShowing {
withAnimation {
showProgressView()
}
}
}
}
}
// MARK: - Private methods
extension ProgressViewModifier {
private func showProgressView() -> some View {
ZStack {
Rectangle()
.fill(backgroundColor.opacity(0.7))
.ignoresSafeArea()
.background(.ultraThinMaterial)
VStack (spacing : 20) {
if isShowing {
ProgressView()
.tint(loaderColor)
.scaleEffect(CGFloat(scale))
if text != nil {
Text(text!)
.foregroundColor(.black)
.font(.headline)
}
}
}
.background(.clear)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
This is the RootTabView, the one containing the TabBar.
struct RootTabView: View {
var body: some View {
TabView {
AddEverydayExpense()
.tabItem {
Label("First", systemImage: "1.circle")
}
AddInvestment()
.tabItem {
Label("Second", systemImage: "2.circle")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
RootTabView()
}
}
This is my view.
struct AddEverydayExpense: View {
#ObservedObject private var model = AddEverydayExpenseVM()
#State private var description: String = ""
#State private var cost: String = ""
#State private var date: Date = Date()
#State private var essential: Bool = false
#State private var month: Month?
#State private var category: Category?
private var isButtonDisabled: Bool {
return description.isEmpty ||
cost.isEmpty ||
month == nil ||
category == nil
}
var body: some View {
NavigationView {
VStack {
Form {
Section {
TextField("", text: $description, prompt: Text("Descrizione"))
TextField("", text: $cost, prompt: Text("10€"))
.keyboardType(.numbersAndPunctuation)
DatePicker(date.string(withFormat: "EEEE"), selection: $date)
HStack {
CheckboxView(checked: $essential)
Text("È considerata una spesa essenziale?")
}
.onTapGesture {
essential.toggle()
}
}
Section {
Picker(month?.name ?? "Mese di riferimento", selection: $month) {
ForEach(model.months) { month in
Text(month.name).tag(month as? Month)
}
}
Picker(category?.name ?? "Categoria", selection: $category) {
ForEach(model.categories) { category in
Text(category.name).tag(category as? Category)
}
}
}
Section {
Button("Invia".uppercased()) { print("Button") }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.font(.headline)
.listRowBackground(isButtonDisabled ? Color.gray.opacity(0.5) : Color.blue)
.foregroundColor(Color.white.opacity(isButtonDisabled ? 0.5 : 1))
.disabled(!isButtonDisabled)
}
}
Spacer()
}
.navigationTitle("Aggiungi Spesa")
}
.progressView(isShowing: $model.isFetching, blur: true)
}
}
As you can see, there is the line .progressView(isShowing: $model.isFetching, blur: true) that does the magic. The problem is that the loader is only shown on the current view, but not on the tab. .
How can I achieve the result?
If you want the progress view to cover the entire view (including the tab bar), it has to be in the view hierarchy at or above the TabBar. Right now, it's below the TabBar in the child views.
Because the state will need to be passed up to the parent (the owner of the TabBar), you'll need some sort of state management that you can pass down to the children. This could mean just passing a Binding to a #State. I've chosen to show how to achieve this with an ObservableObject passed down the hierarchy using an #EnvironmentObject so that you don't have to explicitly pass the dependency.
class ProgressManager : ObservableObject {
#Published var inProgress = false
}
struct ContentView : View {
#StateObject private var progressManager = ProgressManager()
var body: some View {
TabView {
AddEverydayExpense()
.tabItem {
Label("First", systemImage: "1.circle")
}
AddInvestment()
.tabItem {
Label("Second", systemImage: "2.circle")
}
}
.environmentObject(progressManager)
.progressView(isShowing: $progressManager.inProgress) //<-- Note that this is outside of the `TabBar`
}
}
struct AddEverydayExpense : View {
#EnvironmentObject private var progressManager : ProgressManager
var body: some View {
Button("Progress") {
progressManager.inProgress = true
}
}
}

Why is my Swiftui detail view not displaying the data from my tapped list item? [duplicate]

Here is my example, and I can't tell if this is a bug or not. All my cells load correctly, but when I try to bring up the DetailView() as a sheet, the item pased in is always whatevr item is shown first in the grid (in the top left in my case here), NOT the "cell" that was tapped. So, why is the item from ForEach loop correctly populating the cells, but not being passed to the .sheet via the button?
import SwiftUI
let columnCount: Int = 11
let gridSpacing: CGFloat = 1
struct GridView: View {
#State var showingDetail = false
let data = (1...755).map { "\($0)" }
let columns: [GridItem] = Array(repeating: .init(.flexible(), spacing: gridSpacing), count: columnCount)
let colCount: CGFloat = CGFloat(columnCount)
var body: some View {
GeometryReader { geo in
ScrollView (showsIndicators: false) {
LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(data, id: \.self) { item in
Button(action: {
self.showingDetail.toggle()
}) {
GridCell(item: item, size: (geo.size.width - (colCount * gridSpacing)) / colCount)
}.sheet(isPresented: $showingDetail) {
DetailView(item: item)
}
}
}
}
.padding(.horizontal, gridSpacing)
}
}
}
struct GridCell: View {
let isVault: Bool = false
let item: String
let size: CGFloat
var body: some View {
ZStack {
Rectangle()
.fill(Color.orange)
.cornerRadius(4)
Text(item)
.foregroundColor(.white)
.font(.system(size: size * 0.55))
}
.frame(height: size)
}
}
struct DetailView: View {
let item: String
var body: some View {
Text(item)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
GridView()
.preferredColorScheme(.dark)
}
}
What am I missing? Also, scrolling on my iPad pro 11 is a bit jumpy, does anyone esle see the same behaviour?
In such use-case it is more appropriate to use variant of sheet constructed with item, because sheet must be moved out of dynamic content (otherwise you create as many sheets as items in ForEach).
Here is possible solution. Tested with Xcode 12 / iOS 14.
// helper extension because .sheet(item:...) requires item to be Identifiable
extension String: Identifiable {
public var id: String { self }
}
struct GridView: View {
#State private var selected: String? = nil
let data = (1...755).map { "\($0)" }
let columns: [GridItem] = Array(repeating: .init(.flexible(), spacing: gridSpacing), count: columnCount)
let colCount: CGFloat = CGFloat(columnCount)
var body: some View {
GeometryReader { geo in
ScrollView (showsIndicators: false) {
LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(data, id: \.self) { item in
Button(action: {
selected = item // store selected item
}) {
GridCell(item: item, size: (geo.size.width - (colCount * gridSpacing)) / colCount)
}
}
}
}.sheet(item: $selected) { item in // activated on selected item
DetailView(item: item)
}
.padding(.horizontal, gridSpacing)
}
}
}

SwiftUI - View disappears if animated

I am building a custom segmented control. This is the code that I have written.
struct SegmentedControl: View {
private var items: [String] = ["One", "Two", "Three"]
#Namespace var animation:Namespace.ID
#State var selected: String = "One"
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(items, id: \.self) { item in
Button(action: {
withAnimation(.spring()){
self.selected = item
}
}) {
Text(item)
.font(Font.subheadline.weight(.medium))
.foregroundColor(selected == item ? .white : .accentColor)
.padding(.horizontal, 25)
.padding(.vertical, 10)
.background(zStack(item: item))
}
}
} .padding()
}
}
private func zStack(item: String) -> some View {
ZStack{
if selected == item {
Color.accentColor
.clipShape(Capsule())
.matchedGeometryEffect(id: "Tab", in: animation)
} else {
Color(.gray)
.clipShape(Capsule())
}}
}
}
A control is Blue when it is selected.
It works as expected most of the time like in the following GIF.
However, sometimes if you navigate back and forth very fast, the Color.accentColor moves off screen and disappears as you see in the following GIF. I have used a lot of time but could not fix it.
Sometimes, I get this error.
Multiple inserted views in matched geometry group Pair<String,
ID>(first: "Tab", second: SwiftUI.Namespace.ID(id: 248)) have `isSource:
true`, results are undefined.
Info, It is easier to test it on a physical device rather than a simulator.
Update
This is my all codde including the ContentView and the Modal.
struct ContentView: View {
#State private var isPresented: Bool = false
var body: some View {
NavigationView {
VStack {
Button(action: {
self.isPresented.toggle()
}, label: {
Text("Button")
})
}
}
.sheet(isPresented: $isPresented, content: {
ModalView()
})
}
}
struct ModalView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: TabbarView(),
label: {
Text("Navigate")
})
}
}
}
struct TabbarView: View {
private var items: [String] = ["One", "Two", "Three"]
#Namespace var animation:Namespace.ID
#State var selected: String = "" // change here
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(items, id: \.self) { item in
Button(action: {
withAnimation{
self.selected = item
}
}) {
Text(item)
.font(Font.subheadline.weight(.medium))
.foregroundColor(selected == item ? .white : .accentColor)
.padding(.horizontal, 25)
.padding(.vertical, 10)
.background(zStack(item: item))
}
}
} .padding()
}
.onAppear { self.selected = "One" } // add this
}
private func zStack(item: String) -> some View {
ZStack{
if selected == item {
Color.accentColor
.clipShape(Capsule())
.matchedGeometryEffect(id: "Tab", in: animation)
} else {
Color(.gray)
.clipShape(Capsule())
}}
}
}

SwiftUI - Hide custom onDelete View on tap gesture

I have LazyVStack view that contains a list of views. Each one of the views has a different color and there is 8 points space between them. Threrefore, I can not use List.
So I am trying to build a custom trailing swipe that functions similar to the onDelete method of List. This is my code and it is not perfect, but I am on the right directin, I think.
Test Data - List of countries
class Data: ObservableObject {
#Published var countries: [String]
init() {
self.countries = NSLocale.isoCountryCodes.map { (code:String) -> String in
let id = NSLocale.localeIdentifier(fromComponents: [NSLocale.Key.countryCode.rawValue: code])
return NSLocale(localeIdentifier: "en_US").displayName(forKey: NSLocale.Key.identifier, value: id) ?? "Country not found for code: \(code)"
}
}
}
ContentView
struct ContentView: View {
#ObservedObject var data: Data = Data()
var body: some View {
ScrollView {
LazyVStack {
ForEach(data.countries, id: \.self) { country in
VStack {
SwipeView(content: {
VStack(spacing: 0) {
Spacer()
Text(country)
.frame(minWidth: 0, maxWidth: .infinity)
Spacer()
}
.background(Color.yellow)
}, trailingActionView: {
Image(systemName: "trash")
.foregroundColor(.white)
}) {
self.data.countries.removeAll {$0 == country}
}
}
.clipShape(Rectangle())
}
}
}
.padding(.vertical, 16)
}
}
Custom SwipeView
struct SwipeView<Content: View, TrailingActionView: View>: View {
let width = UIScreen.main.bounds.width - 32
#State private var height: CGFloat = .zero
#State var offset: CGFloat = 0
let content: Content
let trailingActionView: TrailingActionView
var onDelete: () -> ()
init(#ViewBuilder content: () -> Content,
#ViewBuilder trailingActionView: () -> TrailingActionView,
onDelete: #escaping () -> Void) {
self.content = content()
self.trailingActionView = trailingActionView()
self.onDelete = onDelete
}
var body: some View {
ZStack {
HStack(spacing: 0) {
Button(action: {
withAnimation {
self.onDelete()
}
}) {
trailingActionView
}
.frame(minHeight: 0, maxHeight: .infinity)
.frame(width: 60)
Spacer()
}
.background(Color.red)
.frame(width: width)
.offset(x: width + self.offset)
content
.frame(width: width)
.contentShape(Rectangle())
.offset(x: self.offset)
.gesture(DragGesture().onChanged(onChanged).onEnded { value in
onEnded(value: value, width: width)
})
}
.background(Color.white)
}
private func onChanged(value: DragGesture.Value) {
let translation = value.translation.width
if translation < 0 {
self.offset = translation
} else {
}
}
private func onEnded(value: DragGesture.Value,width: CGFloat) {
withAnimation(.easeInOut) {
let translation = -value.translation.width
if translation > width - 16 {
self.onDelete()
self.offset = -(width * 2)
}
else if translation > 50 {
self.offset = -50
}
else {
self.offset = 0
}
}
}
}
It has one annoying problem: If you swipe a row and do not delete it. And if you swipe another views, they don not reset. All the trailing Delete Views are visible. But I want to reset/ swipe back if you tap anywhere outside the Delete View.
I want to swipe back if you tap anywhere outside the Delete View. So how to do it?
First off, to know which cell is swiped the SwipeViews needs an id. If you don't want to set them from external I guess this will do:
struct SwipeView<Content: View, TrailingActionView: View>: View {
...
#State var id = UUID()
...
}
Then you need to track which cell is swiped, the SwiftUI way of relaying data to siblings is by a Binding that is saved in it's parent. Read up on how to pass data around SwiftUI Views. If you want to be lazy you can also just have a static object that saves the selected cell:
class SwipeViewHelper: ObservableObject {
#Published var swipedCell: UUID?
private init() {}
static var shared = SwipeViewHelper()
}
struct SwipeView<Content: View, TrailingActionView: View>: View {
...
#ObservedObject var helper = SwipeViewHelper.shared
...
}
Then you have to update the swipedCell. We want the cell to close when we START swiping on a different cell:
private func onChanged(value: DragGesture.Value) {
...
if helper.swipedCell != nil {
helper.swipedCell = nil
}
...
}
And when a cell is open we save it:
private func onEnded(value: DragGesture.Value,width: CGFloat) {
withAnimation(.easeInOut) {
...
else if translation > 50 {
self.offset = -50
helper.swipedCell = id
}
...
}
}
Then we have to respond to changes of the swipedCell. We can do that by adding an onChange inside the body of SwipeView:
.onChange(of: helper.swipedCell, perform: { newCell in
if newCell != id {
withAnimation(.easeInOut) {
self.offset = 0
}
}
})
Working gist: https://gist.github.com/Amzd/61a957a1c5558487f6cc5d3ce29cf508

How to select an item in ScrollView with SwiftUI?

what I am trying to accomplish is have a loop of items where I am able to tap one and it gets bigger programmatically once tapped
here is my code and my results so far:
struct ContentView: View {
#State var emojisArray = ["📚", "🎹", "🎯", "💻"]
#State var selectedIndex = 0
var body: some View {
VStack {
ScrollView(.horizontal) {
HStack {
ForEach(0..<emojisArray.count) { item in
emojiView(emoji: self.emojisArray[item],
isSelected: item == self.selectedIndex ? true : false)
.onTapGesture {
print (item)
self.selectedIndex = item
}
}
}
}
.onAppear()
.frame(height:160)
VStack{
Text("selcted item:")
Text("\(self.emojisArray[self.selectedIndex])")
}
}
}
}
where emojiView is :
struct emojiView: View {
var emoji : String
#State var isSelected : Bool
var body: some View {
Text(emoji)
.font(isSelected ? .system(size: 120) : .system(size: 45))
}
}
I guess the problem is that the ScrollView does't reload itself
Just remove #State in emojiView
struct emojiView: View {
var emoji : String
var isSelected : Bool // << here !!
var body: some View {
Text(emoji)
.font(isSelected ? .system(size: 120) : .system(size: 45))
}
}
Tested with Xcode 12 / iOS 14

Resources