I have 4 tips to show in screen so i have created like this
code:
struct TipContent {
let title: String
let message: String
}
// MARK: - TipKit Manager
class TipKitManager: ObservableObject {
static let shared = TipKitManager()
// All tip contents in a single array
let tipContents: [TipContent] = [
TipContent(title: "Welcome!", message: "Start your journey by logging in to your account."),
TipContent(title: "Enter Username!", message: "Provide your username or email to proceed."),
TipContent(title: "Enter Password!", message: "Use your secure password to log in safely."),
TipContent(title: "Register!", message: "Don't have an account? Tap Register to create one!")
]
@Published var currentTipIndex: Int = 0
@Published var showTip: Bool = true
var currentTip: LoginTip? {
guard showTip && currentTipIndex < tipContents.count else { return nil }
return LoginTip(index: currentTipIndex, content: tipContents[currentTipIndex])
}
var totalSteps: Int {
tipContents.count
}
var currentStep: Int {
currentTipIndex + 1
}
func moveToNextTip() {
if currentTipIndex < tipContents.count - 1 {
currentTip?.invalidate(reason: .tipClosed)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.currentTipIndex += 1
}
} else {
dismissTips()
}
}
func dismissTips() {
currentTip?.invalidate(reason: .tipClosed)
showTip = false
}
func resetTips() {
currentTipIndex = 0
showTip = true
}
}
// MARK: - Dynamic Tip
struct LoginTip: Tip {
let index: Int
let content: TipContent
var title: Text {
Text(content.title)
}
var message: Text? {
Text(content.message)
}
}
// MARK: - Custom Tip View Style (Refactored)
struct NewFeatureTipViewStyle: TipViewStyle {
let step: Int
let totalSteps: Int
var onNext: (() -> Void)? = nil
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.title
.font(.title2)
.bold()
configuration.message
.foregroundStyle(.secondary)
HStack {
// Progress dots
HStack {
ForEach(1...totalSteps, id: \.self) { i in
Capsule()
.fill(i == step ? .green : .gray.opacity(0.4))
.frame(width: 24, height: 6)
}
}
Spacer()
if step < totalSteps {
Button("Next") { onNext?() }
.foregroundStyle(.green)
} else {
Button("Done") {
configuration.tip.invalidate(reason: .tipClosed)
}
.foregroundStyle(.green)
}
}
.padding(.top, 10)
}
.padding(.vertical, 20)
.padding(.horizontal, 24)
}
}
Login screen: here i am showing tips but initially 1st tipview is showing but here if i click next then it suppose to move to 2nd place(email textfield) but it is closed.. not going to 2nd and all 4... where am i wrong.. how to fix
struct LoginView: View {
@StateObject private var tipManager = TipKitManager.shared
var body: some View {
VStack(spacing: 30) {
// MARK: LOGIN HEADER
Text("Login")
.popoverTip(tipManager.currentTipIndex == 0 ? tipManager.currentTip : nil, arrowEdge: .top)
.tipViewStyle(
NewFeatureTipViewStyle(
step: tipManager.currentStep,
totalSteps: tipManager.totalSteps,
onNext: { tipManager.moveToNextTip() }
)
)
VStack(spacing: 20) {
// MARK: USERNAME
TextField("Enter email or username", text: $userName)
.textFieldStyle(.roundedBorder)
.popoverTip(tipManager.currentTipIndex == 1 ? tipManager.currentTip : nil, arrowEdge: .bottom)
.tipViewStyle(
NewFeatureTipViewStyle(
step: tipManager.currentStep,
totalSteps: tipManager.totalSteps,
onNext: { tipManager.moveToNextTip() }
)
)
// MARK: PASSWORD
SecureField("Password", text: $uaerPassword)
.textFieldStyle(.roundedBorder)
.popoverTip(tipManager.currentTipIndex == 2 ? tipManager.currentTip : nil, arrowEdge: .bottom)
.tipViewStyle(
NewFeatureTipViewStyle(
step: tipManager.currentStep,
totalSteps: tipManager.totalSteps,
onNext: { tipManager.moveToNextTip() }
)
)
// MARK: REGISTER
HStack {
Text("Don't have an account?")
.foregroundColor(.gray)
Text("Register here!")
.popoverTip(tipManager.currentTipIndex == 3 ? tipManager.currentTip : nil, arrowEdge: .top)
.tipViewStyle(
NewFeatureTipViewStyle(
step: tipManager.currentStep,
totalSteps: tipManager.totalSteps,
onNext: { tipManager.moveToNextTip() }
)
)
.onTapGesture {
isRegTapped = true
}
}
}
.padding()
Spacer()
}
}
}
2 Answers 2
You seem to be re-inventing the function of a TipGroup. I would suggest, it would be simpler to use this built-in functionality, then you don't need the TipKitManager.
When I tried it, I found that I could only get it working if the tips were all of a different type. This is also how it is being done in the example in the documentation. This means, instead of using LoginTip for all four cases, four different types of Tip are needed.
You could consider using an enum to encapsulate the content for the various tips:
enum TipContent {
case welcome
case username
case password
case register
var title: String {
switch self {
case .welcome: "Welcome!"
case .username: "Enter Username!"
case .password: "Enter Password!"
case .register: "Register!"
}
}
var message: String {
switch self {
case .welcome: "Start your journey by logging in to your account."
case .username: "Provide your username or email to proceed."
case .password: "Use your secure password to log in safely."
case .register: "Don't have an account? Tap Register to create one!"
}
}
}
The tips themselves can then be defined as follows:
struct WelcomeTip: Tip {
let content = TipContent.welcome
var title: Text { Text(content.title) }
var message: Text? { Text(content.message) }
}
struct UsernameTip: Tip {
let content = TipContent.username
var title: Text { Text(content.title) }
var message: Text? { Text(content.message) }
}
struct PasswordTip: Tip {
let content = TipContent.password
var title: Text { Text(content.title) }
var message: Text? { Text(content.message) }
}
struct RegisterTip: Tip {
let content = TipContent.register
var title: Text { Text(content.title) }
var message: Text? { Text(content.message) }
}
NewFeatureTipViewStyle no longer needs a callback for proceeding to the next tip, it can just invalidate the tip being shown:
struct NewFeatureTipViewStyle: TipViewStyle {
let step: Int
let totalSteps: Int
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.title
.font(.title2)
.bold()
configuration.message
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
// Progress dots
HStack {
ForEach(1...totalSteps, id: \.self) { i in
Capsule()
.fill(i == step ? .green : .gray.opacity(0.4))
.frame(width: 24, height: 6)
}
}
Spacer()
Button(step < totalSteps ? "Next" : "Done") {
configuration.tip.invalidate(reason: .tipClosed)
}
.foregroundStyle(.green)
}
.padding(.top, 10)
}
.padding(.vertical, 20)
.padding(.horizontal, 24)
}
}
Here is how LoginView can put the tip group into operation:
struct LoginView: View {
@State private var userName = ""
@State private var userPassword = ""
@State private var isRegTapped = false
let nTips = 4
@State private var loginTips = TipGroup(.ordered) {
WelcomeTip()
UsernameTip()
PasswordTip()
RegisterTip()
}
var body: some View {
VStack(spacing: 30) {
// MARK: LOGIN HEADER
Text("Login")
.popoverTip(loginTips.currentTip as? WelcomeTip, arrowEdge: .top)
.tipViewStyle(NewFeatureTipViewStyle(step: 1, totalSteps: nTips))
VStack(spacing: 20) {
// MARK: USERNAME
TextField("Enter email or username", text: $userName)
.textFieldStyle(.roundedBorder)
.popoverTip(loginTips.currentTip as? UsernameTip)
.tipViewStyle(NewFeatureTipViewStyle(step: 2, totalSteps: nTips))
// MARK: PASSWORD
SecureField("Password", text: $userPassword)
.textFieldStyle(.roundedBorder)
.popoverTip(loginTips.currentTip as? PasswordTip)
.tipViewStyle(NewFeatureTipViewStyle(step: 3, totalSteps: nTips))
// MARK: REGISTER
HStack {
Text("Don't have an account?")
.foregroundColor(.gray)
Text("Register here!")
.popoverTip(loginTips.currentTip as? RegisterTip, arrowEdge: .bottom)
.tipViewStyle(NewFeatureTipViewStyle(step: 4, totalSteps: nTips))
.onTapGesture {
isRegTapped = true
}
}
}
.padding()
Spacer()
}
}
}
Animation
2 Comments
TipGroup supports iOS 18 +... actually my project's minimum version is iOS 16 i am already struggling with it to use.. now tipgroup is 18 +Rather than "fixing" the error, how about we just take it back to the drawing board?
I know certain influencers have made a lot people believe that class's are needed everywhere, but they're really not. They're slower and more prone to bugs. They're good for what they're good for, but everything here can be reliably localized, and I don't believe one is needed here.
So without a reference type class, lets make a value type enum that conforms to Tip to store the data...
enum LoginTipOption: Int , CaseIterable , Tip {
case welcome
case username
case password
case register
var id : Int { self.rawValue }
var title : Text {
switch self {
case .welcome : Text ( "Welcome!" )
case .username : Text ( "Enter Username!" )
case .password : Text ( "Enter Password!" )
case .register : Text ( "Register!" )
}
}
var message: Text? {
switch self {
case .welcome : Text ( "Start your journey by logging in to your account." )
case .username : Text ( "Provide your username or email to proceed." )
case .password : Text ( "Use your secure password to log in safely." )
case .register : Text ( "Don't have an account? Tap Register to create one!" )
}
}
/// Returns the next case, if one exists.
/// Returns `nil` is `self` is the last case.
/// Used by `NewFeatureTipViewStyle` button.
func nextTip() -> LoginTipOption? { .init ( rawValue: self.rawValue + 1 ) }
/// Returns `true` if the `self` is the last case.
/// Used locally by `buttonString` variable.
var isLastStep: Bool { self == Self.allCases.last }
/// Used by `NewFeatureTipViewStyle` button.
var buttonString: String { self.isLastStep ? "Done" : "Next" }
}
Notice it also conforms to CaseIterable and has a RawValue that is an Int.
These two make for an easy iteration. The RawValue also made my nextTip() -> LoginTipOption? function super simple.
And now that that is done, I can set an @State var tip: LoginTipOption? = .welcome in your LoginView.
@available( iOS 18.0 , * )
struct LoginView: View {
init ( ) {
try? Tips.resetDatastore()
try? Tips.configure ( [ .displayFrequency ( .immediate ) ] )
}
@State var tip: LoginTipOption? = .welcome
var body: some View {
VStack ( spacing: 30 ) {
Text ( "Login" )
.tip ( .welcome , currentTip: self.tip , arrowEdge: .top )
VStack ( spacing: 20 ) {
TextField ( "Enter email or username" , text: .constant ( "UserName" ) )
.tip ( .username , currentTip: self.tip , arrowEdge: .top )
SecureField ( "" , text: .constant ( "Hidden" ) )
.tip ( .password , currentTip: self.tip , arrowEdge: .top )
HStack {
Text ( "Don't have an account?" ) .foregroundColor ( .gray )
Text ( "Register here!" )
.tip ( .register , currentTip: self.tip , arrowEdge: .bottom )
}
}
.padding()
Spacer()
}
.textFieldStyle ( .roundedBorder )
.tipViewStyle ( NewFeatureTipViewStyle ( tip: $tip ) )
}
}
I created an extension that delivers backwards compatibility to iOS 18 for .popoverTip(...). For further backwards compatibility, I personally would just create my own TipKit library.
@available( iOS 18.0 , * )
extension View {
@ViewBuilder
func tip ( _ tip: LoginTipOption , currentTip: LoginTipOption? , arrowEdge: Edge? = nil ) -> some View {
if #available( iOS 26.0 , * ) {
// MARK: iOS 26
popoverTip ( tip == currentTip ? tip : nil , arrowEdge: arrowEdge )
} else if let edge = arrowEdge {
// MARK: iOS 18
popoverTip ( tip == currentTip ? tip : nil , arrowEdge: edge )
.presentationCompactAdaptation ( .popover )
} else {
// MARK: iOS 18
popoverTip ( tip == currentTip ? tip : nil )
.presentationCompactAdaptation ( .popover )
}
}
}
And finally, I set your TipViewStyle to run off a Binding<LoginTipOption?>. Most everything is the same except the button updates the @State LoginTipOption located on the LoginView through the Binding<LoginTipOption?>.
@available( iOS 17.0 , * )
struct NewFeatureTipViewStyle: TipViewStyle {
@Binding var tip: LoginTipOption?
func makeBody ( configuration: Configuration ) -> some View {
VStack ( alignment: .leading , spacing: 10 ) {
configuration.title .font ( .title2 ) .bold()
configuration.message .foregroundStyle ( .secondary )
HStack {
HStack {
ForEach ( LoginTipOption.allCases.indices , id: \.self ) { i in
Capsule()
.fill ( i == self.tip?.rawValue ? .green : .gray.opacity ( 0.4 ) )
.frame ( width: 24 , height: 6 )
}
}
Spacer()
Button ( tip?.buttonString ?? "Never" , action: { self.tip = tip?.nextTip() } )
.foregroundStyle ( .green )
}
.padding ( .top , 10 )
}
.padding ( .vertical , 20 )
.padding ( .horizontal , 24 )
}
}
On the last Tip , the nextTip() -> LoginTipOption? function returns nil and the cycle is over.