Implementing AI-Powered Grammar and Style Checker in Mobile Applications
Built-in spell check on iOS and Android catches typos, not context. "I went to the bank" is orthographically correct. "The report was written by me" is technically right, but stylistically weak for business. That's where AI comes in.
Native tools as first layer
Before pulling in an LLM, use what's already on the platform.
iOS NLLanguageRecognizer + UITextChecker cover basic spelling. NLTagger with .lemma and .lexicalClass lets you build simple style rules—e.g., flag passive voice through lemma patterns.
func checkPassiveVoice(in text: String) -> [NSRange] {
let tagger = NLTagger(tagSchemes: [.lexicalClass, .lemma])
tagger.string = text
var findings = [NSRange]()
tagger.enumerateTags(in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .lexicalClass) { tag, range in
// Simplified pattern: form of "be" + participle
if tag == .verb {
let lemma = tagger.tag(at: range.lowerBound, unit: .word, scheme: .lemma).0
if lemma?.rawValue == "be" {
findings.append(NSRange(range, in: text))
}
}
return true
}
return findings
}
For non-English languages, morphology analysis is trickier. NLTagger with various languages works from iOS 16+, accuracy sufficient for basic checks.
AI level: when native isn't enough
LanguageTool API covers grammar for 20+ languages including English, returns specific rules and fix suggestions. Self-hosted Java version for private data. Cloud API pricing is reasonable for B2B products.
// Android—request LanguageTool
data class LTRequest(
val text: String,
val language: String, // "en-US", "de-DE"
val enabledOnly: Boolean = false
)
suspend fun checkGrammar(text: String, lang: String): List<GrammarMatch> {
val response = languageToolApi.check(LTRequest(text, lang))
return response.matches.map { match ->
GrammarMatch(
range = match.offset..(match.offset + match.length),
message = match.message,
rule = match.rule.id,
replacements = match.replacements.take(3).map { it.value }
)
}
}
LanguageTool returns rule.id—e.g., MORFOLOGIK_RULE_EN_US for spelling or PASSIVE_VOICE for style. Filter by type: user can disable style warnings, keep grammar only.
Highlight errors in text
Found errors underlined in input field. iOS—NSAttributedString with .underlineStyle and .underlineColor. Red for grammar, yellow for style—standard convention.
func applyUnderlines(_ matches: [GrammarMatch], to textStorage: NSTextStorage) {
// Remove old underlines first
let fullRange = NSRange(location: 0, length: textStorage.length)
textStorage.removeAttribute(.underlineStyle, range: fullRange)
textStorage.beginEditing()
for match in matches {
let color: UIColor = match.isGrammar ? .systemRed : .systemOrange
textStorage.addAttributes([
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: color
], range: match.nsRange)
}
textStorage.endEditing()
}
Android—SpannableStringBuilder with UnderlineSpan or custom ForegroundColorSpan + UnderlineSpan. Jetpack Compose lacks native way to underline parts of TextField—use BasicTextField with custom visualTransformation.
Debounce and batching
Check doesn't run on every keystroke. Optimal scheme:
- Debounce 800–1200 ms after last change
- Check only changed paragraph, not whole text
- Cache results by paragraph hash—if user reverts, no recheck needed
private var checkWorkItem: DispatchWorkItem?
private var paragraphCache = [String: [GrammarMatch]]()
func scheduleCheck(for paragraph: String) {
checkWorkItem?.cancel()
let hash = paragraph.hashValue.description
if let cached = paragraphCache[hash] {
applyMatches(cached)
return
}
checkWorkItem = DispatchWorkItem { [weak self] in
Task {
let matches = try await self?.grammarService.check(paragraph)
await MainActor.run {
self?.paragraphCache[hash] = matches ?? []
self?.applyMatches(matches ?? [])
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: checkWorkItem!)
}
Timeline estimates
Integrate LanguageTool with highlighting—4–7 days. Full implementation with debounce, cache, check level settings, multilangu support—2–3 weeks.







