SwiftUI menu button is slow to resize when title string changes length - ios

I have the following SwiftUI menu:
import SwiftUI
struct ContentView: View {
private enum Constants {
static let arrowIcon = Image(systemName: "chevron.down")
static let background = Color.blue
static let buttonFont = Font.system(size: 14.0)
}
#State private var menuIndex = 0
private let menuItems = ["This year", "Last Year"]
private var menuTitle: String {
guard menuItems.indices.contains(menuIndex) else { return "" }
return menuItems[menuIndex]
}
// MARK: - Views
var body: some View {
makeMenu()
}
// MARK: - Buttons
private func menuItemTapped(title: String) {
guard let index = menuItems.firstIndex(of: title) else { return }
menuIndex = index
}
// MARK: - Factory
#ViewBuilder private func makeMenu() -> some View {
Menu {
ForEach(menuItems, id: \.self) { title in
Button(title, action: { menuItemTapped(title: title) })
}
} label: {
Text("\(menuTitle) \(Constants.arrowIcon)")
.font(Constants.buttonFont)
.fontWeight(.bold)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When a menu item is tapped the index to the title array is updated causing the Text title to update. This works as desired however the text is slow to resize to the changes Last Year... will briefly show before it resizes correctly.
What am I doing wrong here?

This is because the size of the label is being set but not being changed. If you're okay with it, setting a maxWidth of .infinity on the frame of the Label gives you this capability.
#ViewBuilder private func makeMenu() -> some View {
VStack {
Menu {
ForEach(menuItems, id: \.self) { title in
Button(title, action: { menuItemTapped(title: title) })
}
} label: {
Text("\(menuTitle) \(Constants.arrowIcon)")
.font(Constants.buttonFont)
.fontWeight(.bold)
.frame(maxWidth: .infinity) //--> Add this line
}
}
}

Related

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

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)
}
}
}

Crash when attempting to scroll using ScrollViewReader in a SwiftUI List

I am trying to scroll to a newly appended view in a SwiftUI List using ScrollViewReader but keep crashing with EXC_BAD_INSTRUCTION in scrollTo(_:) after adding a few items. I am using Xcode 14.0.1 and iOS 16.0 simulator.
Here is a minimal demo that exhibits the issue:
struct ContentView: View {
#State var items = [Item]()
#State var scrollItem: UUID? = nil
var body: some View {
NavigationView {
ScrollViewReader { proxy in
List {
ForEach(items) { item in
Text(item.id.uuidString)
.id(item.id)
}
}
.listStyle(.inset)
.onChange(of: scrollItem) { newValue in
proxy.scrollTo(newValue)
}
}
.navigationTitle("List Demo")
.toolbar {
Button("Add") {
addItem()
}
}
}
}
func addItem() {
items.append(Item())
scrollItem = items.last?.id
}
}
struct Item: Identifiable {
let id = UUID()
}
I can get past the issue using a ScrollView instead of a List, but I would like to use the native swipe-to-delete functionality in the real project.
List is not supported well in ScrollViewReader. See this thread.
This solution is ugly, but works. The bad thing is that list blinks when you add a new item. I used one of the ideas from the thread above.
import SwiftUI
struct ContentView: View {
#State var items = [Item]()
#State var scrollItem: UUID? = nil
#State var isHidingList = false
var body: some View {
NavigationView {
VStack(alignment: .leading) {
if isHidingList {
list.hidden()
} else {
list
}
}
.onChange(of: scrollItem) { _ in
DispatchQueue.main.async {
self.isHidingList = false
}
}
.navigationTitle("List Demo")
.toolbar {
Button("Add") {
addItem()
}
}
}
}
var list: some View {
ScrollViewReader { proxy in
List {
ForEach(items) { item in
Text(item.id.uuidString)
.id(item.id)
}
}
.listStyle(.inset)
.onChange(of: scrollItem) { newValue in
guard !isHidingList else { return }
proxy.scrollTo(newValue)
}
.onAppear() {
guard !isHidingList else { return }
proxy.scrollTo(scrollItem)
}
}
}
func addItem() {
isHidingList = true
items.append(Item())
scrollItem = items.last?.id
}
}
struct Item: Identifiable {
let id = UUID()
}
Here's a version using the introspect library to find the underlying UIScrollView and scrolling it directly.
Two different .introspect() modifiers are used because in iOS 16 List is implemented with UICollectionView whereas in earlier versions UITableView is used.
There's no flickering / forced rendering using this method as it interacts with the .setContentOffset() directly.
struct ScrollListOnChangeIos16: View {
#State private var items: [String]
init() {
_items = State(initialValue: Array(0...25).map { "Placeholder \($0)" } )
}
// The .introspectX view modifiers will populate scroller
// they are actually UITableView or UICollectionView which both decend from UIScrollView
// https://github.com/siteline/SwiftUI-Introspect/releases/tag/0.1.4
#State private var scroller: UIScrollView?
func scrollToTop() {
scroller?.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
}
func scrollToBottom() {
// Making this async seems to make scroll more consistent to happen after
// items has been updated. *shrug?*
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
guard let scroller = self.scroller else { return }
let yOffset = scroller.contentSize.height - scroller.frame.height
if yOffset < 0 { return }
scroller.setContentOffset(CGPoint(x: 0, y: yOffset), animated: true)
}
}
var body: some View {
VStack {
HStack {
Button("Top") {
scrollToTop()
}
Spacer()
Button("Add Item") {
items.append(Date.timeIntervalSinceReferenceDate.description)
scrollToBottom()
}.buttonStyle(.borderedProminent)
Spacer()
Button("Bottom") {
scrollToBottom()
}
}.padding()
// The source of all my pain ...
List{
ForEach(items, id: \.self) {
Text($0)
}
.onDelete { offsets in
items.remove(atOffsets: offsets)
}
}
.listStyle(.plain)
.padding(.bottom, 50)
}
/* in iOS 16 List is backed by UICollectionView, no out of the box .introspectMethod ... nbd. */
.introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: { (collectionView: UICollectionView) in
guard #available(iOS 16, *) else { return }
self.scroller = collectionView
})
/* in iOS 15 List is backed by UITableView ... */
.introspectTableView(customize: { tableView in
guard #available(iOS 15, *) else { return }
self.scroller = tableView
})
}
}

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
}
}
}

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

Aligning HStack without equal sizing in SwiftUI

I have a custom chart with some texts as seen below. It aligns the first text correctly, it aligns the last text incorrectly. I need the VStack with the texts to stretch like an accordion so that the last text's centerY aligns with the bottom of the chart.
As seen with the blue Xcode highlight, the VStack receives the same size as the chart with an offset.
To align the first text, I extended VerticalAlignment as seen in WWDC 2019 - Session 237.
extension VerticalAlignment {
private enum TopChartAndMidTitle: AlignmentID {
static func defaultValue(in dimensions: ViewDimensions) -> Length {
return dimensions[.top]
}
}
static let topChartAndMidTitle = VerticalAlignment(TopChartAndMidTitle.self)
}
Then I used it like this:
var labels = ["1900", "1800", "1700", "1600", "1500", "1400"]
var body: some View {
HStack(alignment: .topChartAndMidTitle) {
chart()
.alignmentGuide(.topChartAndMidTitle) { $0[.top] }
valueLabels()
}
}
private func valueLabels() -> AnyView {
AnyView(VStack {
Text(labels[0]).font(.footnote)
.alignmentGuide(.topChartAndMidTitle) { $0[.bottom] / 2 }
ForEach(labels.dropFirst().identified(by: \.self)) { label in
Spacer()
Text(label).font(.footnote)
}
})
}
Question: How do I align the last text against the chart bottom and thereby stretch the VStack?
To test this, just replace my custom chart with Rectangle().fill(Color.red). It will result in the same problem.
Today I would do it with the following approach...
Result demo
Code (TopChartAndMidTitle used from original question "as is")
struct TestAlignScales: View {
var labels = ["1900", "1800", "1700", "1600", "1500", "1400"]
#State private var graphHeight = CGFloat.zero
var body: some View {
HStack(alignment: .topChartAndMidTitle) {
Rectangle().fill(Color.red)
.alignmentGuide(.topChartAndMidTitle) { d in
if self.graphHeight != d.height {
self.graphHeight = d.height
}
return d[.top]
}
valueLabels()
}
}
#State private var delta = CGFloat.zero
private func valueLabels() -> some View {
VStack {
ForEach(labels, id: \.self) { label in
VStack {
if label == self.labels.first! {
Text(label).font(.footnote)
.alignmentGuide(.topChartAndMidTitle) { d in
if self.delta != d.height {
self.delta = d.height
}
return d[VerticalAlignment.center]
}
} else {
Text(label).font(.footnote)
}
if label != self.labels.last! {
Spacer()
}
}
}
}
.frame(height: graphHeight + delta)
}
}
struct TestAlignScales_Previews: PreviewProvider {
static var previews: some View {
TestAlignScales()
.frame(height: 400)
}
}

Resources