You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I’m working on a custom view navigation case where child features determine a parent view’s content. I propose a solution using view preferences and a custom view to mimic the behavior of a native sheet/popover. My solution appears to function, but it’s deceptive: Its use of AnyView leads to a nonsensical Equatable conformance that may be okay in specific cases but not generally.
Notes:
KeyboardAccessoryView’s presentation and dismissal slide transition need to be maintained across arbitrary changes to the navigation state (e.g., deep linking).
My PreferenceKey.Value (KeyboardAccessoryContent) is problematic because it has an AnyView property, which precludes a reasonable Equatable conformance (as noted in the code).
My app "docks” the KeyboardAccessoryView to the system keyboard (hence the name). I omitted the docking behavior here because it’s complex and not directly relevant; however, I’d be happy to share the completed implementation.
I fashioned my solution as a swiftui-navigation case study. I’ve included an Xcode Preview, but the animations aren’t behaving well in the Preview. I recommend running my solution in the Simulator.
Am I missing a way to implement Equatable for KeyboardAccessoryContent? Are there completely different approaches to consider?
Thank you in advance for your feedback and suggestions. They are greatly appreciated!
Here’s a video of my solution in action:
solution-wip.mp4
Here’s my work-in-progress solution:
import SwiftUI
import SwiftUINavigation
privateletreadMe=""" This (WIP) case study demonstrates how to drive a parent SwiftUI component off of optional or \ enum child state using view preferences. Sometimes it is necessary to decouple the SwiftUI view hierarchy from the feature hierarchy. \ The KeyboardAccessoryView in this file demonstrates a case where the intended visual transition \ requires that the KeyboardAccessoryView be placed at a higher level in the view hierarchy than \ the rest of the feature's views."""structCustomPreferencesParentView:View{@ObservedObjectprivatevarmodel=FeatureModel()varreadMeText:someView{Text(readMe).fixedSize(horizontal: false, vertical: true).padding()}varfeatureNavButtons:someView{HStack{Button("Random"){withAnimation{self.model.randomButtonTapped()}
// withAnimation { self.model.stepButtonTapped() } // scripted "randomness" for video
}Button("Root"){withAnimation{self.model.rootButtonTapped()}}Button("Complex"){withAnimation{self.model.complexChildButtonTapped()}}Button("Simple"){withAnimation{self.model.simpleChildButtonTapped()}}}.buttonStyle(.bordered)}varbody:someView{VStack{
// self.readMeText
self.featureNavButtons
.padding()VStack{Text("Root")
switch self.model.destination {caselet.complexChild(destination):ComplexChildView(model:ComplexChildModel(destination: destination)).padding().border(.black)caselet.simpleChild(destination):SimpleChildView(model:SimpleChildModel(destination: destination)).padding().border(.black)case.none:Text("(no child)")}}.padding().border(.black)Spacer()}.frame(maxWidth:.infinity).keyboardAccessory() // 👈 custom modifier attaches KeyboardAccessoryView
}}privatefinalclassFeatureModel:ObservableObject{enumDestination:Equatable{case complexChild(ComplexChildModel.Destination?)case simpleChild(SimpleChildModel.Destination?)staticvarrandom:Self?{[nil,Self.complexChild(nil),.complexChild(.step1),.complexChild(.step2),.complexChild(.step3),.complexChild(.end),.simpleChild(nil),.simpleChild(.accessory),].randomElement()!
}staticfunc demoDestination(offset:Int)->Self?{letdestinations:[Self?]=[Self.complexChild(.step2),.simpleChild(.accessory),nil,]returndestinations[offset % destinations.count]}}@Publishedvardestination:Destination?=nilinit(destination:Destination?=nil){self.destination = destination
}privatevaroffset=0func stepButtonTapped(){self.destination =Destination.demoDestination(offset:self.offset)self.offset +=1}func randomButtonTapped(){varnewDestination:Destination?
repeat {
newDestination =Destination.random
} while newDestination ==self.destination
self.destination = newDestination
}func rootButtonTapped(){self.destination =nil}func complexChildButtonTapped(){self.destination =.complexChild(nil)}func simpleChildButtonTapped(){self.destination =.simpleChild(nil)}}privatefinalclassComplexChildModel:ObservableObject{enumDestination:String{case step1 ="Step 1"case step2 ="Step 2"case step3 ="Step 3"case end ="End"varnext:Destination?{
switch self{case.step1:return.step2
case.step2:return.step3
case.step3:return.end
case.end:returnnil}}varprevious:Destination?{
switch self{case.step1:returnnilcase.step2:return.step1
case.step3:return.step2
case.end:return.step3
}}}@Publishedvardestination:Destination?init(destination:Destination?=nil){self.destination = destination
}func toggleAccessoryButtonTapped(){self.destination =self.destination ==nil?.step1 : nil}func nextButtonTapped(){self.destination =self.destination?.next
}func previousButtonTapped(){self.destination =self.destination?.previous
}}privatefinalclassSimpleChildModel:ObservableObject{enumDestination:String{case accessory ="Accessory"}@Publishedvardestination:Destination?init(destination:Destination?=nil){self.destination = destination
}func toggleAccessoryButtonTapped(){self.destination =self.destination ==nil?.accessory : nil}}privatestructSimpleChildView:View{@ObservedObjectfileprivatevarmodel:SimpleChildModelprivateenumSimpleAccessoryID{}varbody:someView{VStack{Text("**`SimpleChildView`**")Text("\(self.model.destination?.rawValue ??"nil")")Button("Toggle accessory"){withAnimation{self.model.toggleAccessoryButtonTapped()}}}
// 👇 specify the content for the parent view
.keyboardAccessoryContent(
id:SimpleAccessoryID.self,
unwrapping:self.$model.destination
){ _ inText("**`SimpleChildView`** accessory content")}}}privatestructComplexChildView:View{@ObservedObjectfileprivatevarmodel:ComplexChildModelprivateenumComplexChildAccessoryID{}varbody:someView{VStack{Text("**`ComplexChildView`**")Text("\(self.model.destination?.rawValue ??"nil")")Button("Toggle accessory"){withAnimation{self.model.toggleAccessoryButtonTapped()}}}
// 👇 specify the content for the parent view
.keyboardAccessoryContent(
id:ComplexChildAccessoryID.self,
unwrapping:self.$model.destination
){ $destination inVStack{Text("**`ComplexChildView`** accessory content").padding(.bottom)Text("*\(destination.rawValue)*")HStack{Button("Next"){self.model.nextButtonTapped()}.disabled(destination.next ==nil)Button("Previous"){self.model.previousButtonTapped()}.disabled(destination.previous ==nil)}.buttonStyle(.borderedProminent)}}}}extensionView{fileprivatefunc keyboardAccessory()->someView{self.modifier(KeyboardAccessoryModifier())}}privatestructKeyboardAccessoryModifier:ViewModifier{@StatevarkeyboardAccessoryContents:[KeyboardAccessoryContent]=[]func body(content:Content)->someView{
content.overlay(ZStack(alignment:.bottom){KeyboardAccessoryView(contents:self.keyboardAccessoryContents)}.ignoresSafeArea()).onPreferenceChange(KeyboardAccessoryContentKey.self){ values inwithAnimation{self.keyboardAccessoryContents = values
}}}}privatestructKeyboardAccessoryContentKey:PreferenceKey{staticvardefaultValue:[KeyboardAccessoryContent]=[]staticfunc reduce(
value:inout[KeyboardAccessoryContent],
nextValue:()->[KeyboardAccessoryContent]){
value.append(contentsOf:nextValue())}}privatestructKeyboardAccessoryContent:Identifiable,Equatable{varid:AnyHashableletcontent:AnyView
// NB: I don't know of any reasonable way to implement equality of two AnyView's.
staticfunc==(lhs:Self, rhs:Self)->Bool{return false // 🛑 don't do this
}}privatestructKeyboardAccessoryView:View{varcontents:[KeyboardAccessoryContent]varbody:someView{VStack(spacing:0){Spacer()
// todo: replace ForEach with IfLet and a purple warning if `contents` has more than one item
ForEach(self.contents){ wrapper in
wrapper.content
.padding().padding(.bottom,30).frame(maxWidth:.infinity).background(Color.gray).clipShape(RoundedRectangle(cornerRadius:15, style:.continuous)).transition(.move(edge:.bottom))}}.ignoresSafeArea()}}privatestructKeyboardAccessoryContentModifier<AccessoryContent>:ViewModifierwhere AccessoryContent:View{letid:Any.Type@BindingvarisActive:Boolletcontent:()->AccessoryContentfunc body(content:Content)->someView{
if self.isActive {
content.preference(
key:KeyboardAccessoryContentKey.self,
value:[KeyboardAccessoryContent(
id:ObjectIdentifier(self.id),
content:AnyView(self.content()))])}else{
content
}}}extensionView{@ViewBuilderfileprivatefunc keyboardAccessoryContent<Content>(
id:Any.Type,
isActive:Binding<Bool>,@ViewBuilder content:@escaping()->Content)->someViewwhere Content:View{self.modifier(KeyboardAccessoryContentModifier(
id: id,
isActive: isActive,
content: content
))}fileprivatefunc keyboardAccessoryContent<Value, Content>(
id:Any.Type,
unwrapping value:Binding<Value?>,@ViewBuilder content:@escaping(Binding<Value>)->Content)->someViewwhere Content:View{self.modifier(KeyboardAccessoryContentModifier(
id: id,
isActive: value.isPresent(),
content:{Binding(unwrapping: value).map(content)}))}fileprivatefunc keyboardAccessoryContent<Enum, Case, Content>(
id:Any.Type,
unwrapping value:Binding<Enum?>,
case casePath:CasePath<Enum,Case>,@ViewBuilder content:@escaping(Binding<Case>)->Content)->someViewwhere Content:View{self.keyboardAccessoryContent(
id: id,
unwrapping: value.case(casePath),
content: content
)}}structCustomViewPreferences_Previews:PreviewProvider{staticvarpreviews:someView{CustomPreferencesParentView()}}
Addendum: My app’s simplified use case
My app’s simplified use case
Here’s the design I’m implementing:
design.mov
My app starts in the UnnamedUser case and transitions to the NamedUser case when the user submits their name.
The design requires an instant transition from UnnamedUserView to NamedUserView, while the KeyboardAccessoryView, which contains the TextField, slides down in sync with the keyboard. UnnamedUserView and NamedUserView reside in separate modules and have significant structural differences, such as the NamedUserView's content being within a scroll view.
The presentation and content of the KeyboardAccessoryView are controlled by the UnnamedUser feature. However, if I attach the KeyboardAccessoryView as a child of UnnamedUserView, I cannot control the KeyboardAccessoryView's transition because UnnamedUserView’s parent is the subject of the transition.
This design video shows a simplified example of the UI pattern that my app uses. The KeyboardAccessoryView needs to be able to handle arbitrary view content – typical uses are more complex.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
-
Hello everyone!
I’m working on a custom view navigation case where child features determine a parent view’s content. I propose a solution using view preferences and a custom view to mimic the behavior of a native sheet/popover. My solution appears to function, but it’s deceptive: Its use of AnyView leads to a nonsensical Equatable conformance that may be okay in specific cases but not generally.
Notes:
Am I missing a way to implement Equatable for KeyboardAccessoryContent? Are there completely different approaches to consider?
Thank you in advance for your feedback and suggestions. They are greatly appreciated!
Here’s a video of my solution in action:
solution-wip.mp4
Here’s my work-in-progress solution:
Addendum: My app’s simplified use case
My app’s simplified use case
Here’s the design I’m implementing:
design.mov
My app starts in the UnnamedUser case and transitions to the NamedUser case when the user submits their name.
The design requires an instant transition from UnnamedUserView to NamedUserView, while the KeyboardAccessoryView, which contains the TextField, slides down in sync with the keyboard. UnnamedUserView and NamedUserView reside in separate modules and have significant structural differences, such as the NamedUserView's content being within a scroll view.
The presentation and content of the KeyboardAccessoryView are controlled by the UnnamedUser feature. However, if I attach the KeyboardAccessoryView as a child of UnnamedUserView, I cannot control the KeyboardAccessoryView's transition because UnnamedUserView’s parent is the subject of the transition.
This design video shows a simplified example of the UI pattern that my app uses. The KeyboardAccessoryView needs to be able to handle arbitrary view content – typical uses are more complex.
Beta Was this translation helpful? Give feedback.
All reactions